Skip to main content

falcon_library/
mhtml.rs

1//! MHTML web archives (`.mhtml`): reading the MIME parts (RFC 2045, 2046,
2//! 2047 and 2557) and unpacking them into a folder a browser engine can
3//! load from disk.
4//!
5//! WebKit on macOS doesn't open MHTML, so the web viewer and the import
6//! thumbnail use [`unpack`]: every part becomes a file in a fresh folder
7//! (the root page as `index.html`), and the references in the HTML and CSS
8//! parts that point at other parts, by `Content-Location` or `cid:`, are
9//! rewritten to the files' relative names. Whatever isn't in the archive is
10//! left as written, and a Content Security Policy put at the top of every
11//! page lets it load only `file:`, `data:` and `blob:` resources, so
12//! nothing is fetched from the network.
13//!
14//! The reader tolerates CRLF or LF line ends, a missing closing boundary,
15//! folded headers, nested multiparts, quoted-printable with `=0D` line
16//! ends, encoded words in headers, and the shapes Chrome, Edge and Word
17//! write ("Save as MHTML": full URLs as `Content-Location`, `cid:`
18//! references, `Snapshot-Content-Location`). Text parts are decoded from
19//! their charset with `encoding_rs` and written as UTF-8 with a byte order
20//! mark, which takes precedence over any charset the page declares.
21//!
22//! QuickLook's HTML renderer loads a page's files through a scheme of its
23//! own, which no policy source matches, so the import thumbnail unpacks
24//! without the policy ([`Policy::None`]); QuickLook fetches nothing from
25//! the network itself.
26//!
27//! Unpacking is bounded by [`Limits`] (total decoded size and part count),
28//! and file names are made by falcon, never taken as paths from the
29//! archive, so nothing is written outside the target folder.
30
31use std::collections::HashMap;
32use std::fs;
33use std::path::{Component, Path, PathBuf};
34
35use base64::Engine as _;
36
37/// How much an archive may unpack to, by default: 256 MB of decoded parts.
38pub const MAX_TOTAL: u64 = 256 * 1024 * 1024;
39/// How many parts an archive may have, by default.
40pub const MAX_PARTS: usize = 20_000;
41/// How deeply multiparts may nest.
42const MAX_DEPTH: usize = 8;
43/// The root page's file name in the unpacked folder.
44pub const ROOT_NAME: &str = "index.html";
45
46/// The policy put at the top of every unpacked page: the archive's own
47/// files and inline data load, the network doesn't.
48pub const CSP: &str = "default-src 'none'; img-src file: data: blob:; media-src file: data: blob:; font-src file: data:; \
49style-src file: data: 'unsafe-inline'; script-src file: 'unsafe-inline' 'unsafe-eval'; frame-src file: data:; \
50child-src file: data:; object-src 'none'; connect-src 'none'; form-action 'none'";
51
52/// Whether unpacked pages carry [`CSP`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Policy {
55    /// The policy goes at the top of every page (the live viewer).
56    Offline,
57    /// No policy (QuickLook, which doesn't fetch on its own).
58    None,
59}
60
61/// Bounds on what an archive may unpack to.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Limits {
64    /// The decoded size of all parts together, in bytes.
65    pub max_total: u64,
66    /// The number of parts, nested ones included.
67    pub max_parts: usize,
68}
69
70impl Default for Limits {
71    fn default() -> Self {
72        Limits { max_total: MAX_TOTAL, max_parts: MAX_PARTS }
73    }
74}
75
76/// Why an archive couldn't be read or unpacked.
77#[derive(Debug, thiserror::Error)]
78pub enum MhtmlError {
79    #[error("not a MIME web archive")]
80    NotArchive,
81    #[error("the archive has no parts")]
82    Empty,
83    #[error("the archive unpacks to more than {0} bytes")]
84    TooLarge(u64),
85    #[error("the archive has more than {0} parts")]
86    TooManyParts(usize),
87    #[error("the archive's parts are nested too deeply")]
88    TooDeep,
89    #[error("part {0} can't be decoded: {1}")]
90    BadPart(usize, String),
91    #[error("refused to write {0:?} outside the folder")]
92    UnsafeName(String),
93    #[error(transparent)]
94    Io(#[from] std::io::Error),
95}
96
97/// One part of an archive, decoded from its transfer encoding.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Part {
100    /// The MIME type, lower-cased (`text/html`); `text/plain` when the part
101    /// gives none, as RFC 2045 says.
102    pub content_type: String,
103    /// The `charset` parameter, as written.
104    pub charset: Option<String>,
105    /// `Content-Location`, with encoded words decoded.
106    pub location: Option<String>,
107    /// `Content-ID`, without its angle brackets.
108    pub content_id: Option<String>,
109    /// The bytes, transfer-decoded (still in `charset` for text).
110    pub body: Vec<u8>,
111}
112
113impl Part {
114    fn is_html(&self) -> bool {
115        matches!(self.content_type.as_str(), "text/html" | "application/xhtml+xml")
116    }
117
118    fn is_css(&self) -> bool {
119        self.content_type == "text/css"
120    }
121}
122
123/// A parsed archive.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Archive {
126    /// Every leaf part in order, nested multiparts flattened.
127    pub parts: Vec<Part>,
128    /// The index of the root page: the part `start=` names, else the first
129    /// HTML part, else the first part.
130    pub root: usize,
131    /// The `Subject` header (the page title Chrome saves), decoded.
132    pub subject: Option<String>,
133    /// The page's address: `Snapshot-Content-Location`, else the root
134    /// part's `Content-Location` when it is a web address.
135    pub address: Option<String>,
136}
137
138/// Header names lower-cased with their unfolded values, in order.
139#[derive(Debug, Default)]
140struct Headers(Vec<(String, String)>);
141
142impl Headers {
143    fn get(&self, name: &str) -> Option<&str> {
144        self.0.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
145    }
146}
147
148/// Splits `data` into its header block and body at the first empty line.
149/// Leading empty lines are skipped. Without an empty line it is all head.
150fn split_head(data: &[u8]) -> (&[u8], &[u8]) {
151    let mut start = 0;
152    while start < data.len() {
153        let rest = &data[start..];
154        if rest.starts_with(b"\r\n") {
155            start += 2;
156        } else if rest.starts_with(b"\n") {
157            start += 1;
158        } else {
159            break;
160        }
161    }
162    let data = &data[start..];
163    let mut i = 0;
164    while let Some(nl) = data[i..].iter().position(|&b| b == b'\n') {
165        let at = i + nl + 1;
166        let rest = &data[at..];
167        if rest.starts_with(b"\r\n") {
168            return (&data[..at], &data[at + 2..]);
169        }
170        if rest.starts_with(b"\n") {
171            return (&data[..at], &data[at + 1..]);
172        }
173        i = at;
174    }
175    (data, &[])
176}
177
178/// Reads a header block: one header per line, continuation lines (starting
179/// with a space or tab) appended to the one before.
180fn parse_headers(head: &[u8]) -> Headers {
181    let text = String::from_utf8_lossy(head);
182    let mut out: Vec<(String, String)> = Vec::new();
183    for line in text.split('\n') {
184        let line = line.strip_suffix('\r').unwrap_or(line);
185        if line.starts_with([' ', '\t']) {
186            if let Some((_, v)) = out.last_mut() {
187                v.push(' ');
188                v.push_str(line.trim());
189            }
190            continue;
191        }
192        if let Some((name, value)) = line.split_once(':') {
193            let name = name.trim();
194            if !name.is_empty() && !name.contains(' ') {
195                out.push((name.to_ascii_lowercase(), value.trim().to_string()));
196            }
197        }
198    }
199    Headers(out)
200}
201
202/// Splits a structured header value (`type/sub; a=b; c="d"`) into its
203/// first item, lower-cased, and its parameters (names lower-cased,
204/// values unquoted).
205fn parse_params(value: &str) -> (String, Vec<(String, String)>) {
206    let mut items = Vec::new();
207    let mut cur = String::new();
208    let mut quoted = false;
209    let mut chars = value.chars();
210    while let Some(c) = chars.next() {
211        match c {
212            '"' => {
213                quoted = !quoted;
214                cur.push(c);
215            }
216            '\\' if quoted => {
217                cur.push(c);
218                if let Some(n) = chars.next() {
219                    cur.push(n);
220                }
221            }
222            ';' if !quoted => items.push(std::mem::take(&mut cur)),
223            _ => cur.push(c),
224        }
225    }
226    items.push(cur);
227    let mut it = items.into_iter();
228    let first = it.next().unwrap_or_default().trim().to_ascii_lowercase();
229    let params = it
230        .filter_map(|p| {
231            let (k, v) = p.split_once('=')?;
232            Some((k.trim().to_ascii_lowercase(), unquote(v.trim())))
233        })
234        .collect();
235    (first, params)
236}
237
238fn unquote(v: &str) -> String {
239    let Some(inner) = v.strip_prefix('"') else { return v.to_string() };
240    let inner = inner.strip_suffix('"').unwrap_or(inner);
241    let mut out = String::with_capacity(inner.len());
242    let mut chars = inner.chars();
243    while let Some(c) = chars.next() {
244        if c == '\\' {
245            if let Some(n) = chars.next() {
246                out.push(n);
247            }
248        } else {
249            out.push(c);
250        }
251    }
252    out
253}
254
255fn param<'a>(params: &'a [(String, String)], name: &str) -> Option<&'a str> {
256    params.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
257}
258
259/// Splits a multipart body into its parts at `--boundary` lines. A part
260/// runs to the line break before the next delimiter; without a closing
261/// delimiter the last part runs to the end. `None` when no delimiter is
262/// found at all.
263fn split_multipart<'a>(body: &'a [u8], boundary: &str) -> Option<Vec<&'a [u8]>> {
264    let needle = format!("--{boundary}");
265    let needle = needle.as_bytes();
266    let mut parts = Vec::new();
267    // Where the current part's content starts, once the first delimiter
268    // has been seen.
269    let mut open: Option<usize> = None;
270    let mut line = 0;
271    while line <= body.len() {
272        let end = body[line..].iter().position(|&b| b == b'\n').map_or(body.len(), |p| line + p);
273        let text = &body[line..end];
274        if let Some(rest) = text.strip_prefix(needle) {
275            let rest = rest.strip_suffix(b"\r").unwrap_or(rest);
276            let close = rest.starts_with(b"--");
277            if close || rest.iter().all(|b| b.is_ascii_whitespace()) {
278                if let Some(start) = open {
279                    // The line break before the delimiter belongs to it.
280                    let mut stop = line.max(start);
281                    if stop > start && body[stop - 1] == b'\n' {
282                        stop -= 1;
283                        if stop > start && body[stop - 1] == b'\r' {
284                            stop -= 1;
285                        }
286                    }
287                    parts.push(&body[start..stop]);
288                }
289                if close {
290                    return Some(parts);
291                }
292                open = Some((end + 1).min(body.len()));
293            }
294        }
295        line = end + 1;
296    }
297    let start = open?;
298    if start < body.len() {
299        parts.push(&body[start..]);
300    }
301    Some(parts)
302}
303
304/// Decodes base64 leniently: whitespace and stray characters are skipped,
305/// padding is optional and a dangling last character is dropped.
306fn decode_base64(data: &[u8]) -> Result<Vec<u8>, String> {
307    let mut clean: Vec<u8> = data
308        .iter()
309        .filter_map(|&c| match c {
310            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => Some(c),
311            b'-' => Some(b'+'),
312            b'_' => Some(b'/'),
313            _ => None,
314        })
315        .collect();
316    if clean.len() % 4 == 1 {
317        clean.pop();
318    }
319    let engine = base64::engine::GeneralPurpose::new(
320        &base64::alphabet::STANDARD,
321        base64::engine::GeneralPurposeConfig::new()
322            .with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireNone)
323            .with_decode_allow_trailing_bits(true),
324    );
325    engine.decode(&clean).map_err(|e| e.to_string())
326}
327
328fn hex(b: u8) -> Option<u8> {
329    match b {
330        b'0'..=b'9' => Some(b - b'0'),
331        b'a'..=b'f' => Some(b - b'a' + 10),
332        b'A'..=b'F' => Some(b - b'A' + 10),
333        _ => None,
334    }
335}
336
337/// Decodes quoted-printable: `=XX` escapes (either case), soft line breaks
338/// (`=` at a line end, also with trailing blanks), and an `=` that starts
339/// neither kept as it is.
340fn decode_qp(data: &[u8]) -> Vec<u8> {
341    let mut out = Vec::with_capacity(data.len());
342    let mut i = 0;
343    while i < data.len() {
344        let c = data[i];
345        if c != b'=' {
346            out.push(c);
347            i += 1;
348            continue;
349        }
350        if let (Some(&a), Some(&b)) = (data.get(i + 1), data.get(i + 2))
351            && let (Some(h), Some(l)) = (hex(a), hex(b))
352        {
353            out.push(h << 4 | l);
354            i += 3;
355            continue;
356        }
357        // A soft line break, possibly with blanks before it.
358        let mut j = i + 1;
359        while matches!(data.get(j), Some(b' ' | b'\t')) {
360            j += 1;
361        }
362        match data.get(j) {
363            Some(b'\r') if data.get(j + 1) == Some(&b'\n') => i = j + 2,
364            Some(b'\n') => i = j + 1,
365            None => i = j,
366            _ => {
367                out.push(b'=');
368                i += 1;
369            }
370        }
371    }
372    out
373}
374
375/// Turns the `\r\r\n` that some writers leave (a quoted-printable `=0D`
376/// before a real line break) into a single `\r\n`.
377fn fix_cr(data: Vec<u8>) -> Vec<u8> {
378    if !data.windows(3).any(|w| w == b"\r\r\n") {
379        return data;
380    }
381    let mut out = Vec::with_capacity(data.len());
382    let mut i = 0;
383    while i < data.len() {
384        if data[i..].starts_with(b"\r\r\n") {
385            i += 1;
386            continue;
387        }
388        out.push(data[i]);
389        i += 1;
390    }
391    out
392}
393
394/// Decodes RFC 2047 encoded words (`=?utf-8?Q?Caf=C3=A9?=`,
395/// `=?iso-8859-1?B?...?=`) in a header value; blanks between two encoded
396/// words are dropped, unknown charsets and malformed words are kept as
397/// written.
398pub fn decode_words(value: &str) -> String {
399    let mut out = String::new();
400    let mut rest = value;
401    let mut after_word = false;
402    while let Some(at) = rest.find("=?") {
403        let (before, from) = rest.split_at(at);
404        let decoded = encoded_word(from);
405        match decoded {
406            Some((text, used)) => {
407                if !(after_word && before.chars().all(char::is_whitespace)) {
408                    out.push_str(before);
409                }
410                out.push_str(&text);
411                rest = &from[used..];
412                after_word = true;
413            }
414            None => {
415                out.push_str(before);
416                out.push_str("=?");
417                rest = &from[2..];
418                after_word = false;
419            }
420        }
421    }
422    out.push_str(rest);
423    out
424}
425
426/// One encoded word at the start of `s`: its text and its length.
427fn encoded_word(s: &str) -> Option<(String, usize)> {
428    let inner = s.strip_prefix("=?")?;
429    let q1 = inner.find('?')?;
430    let charset = &inner[..q1];
431    let rest = &inner[q1 + 1..];
432    let q2 = rest.find('?')?;
433    let enc = &rest[..q2];
434    let rest = &rest[q2 + 1..];
435    let end = rest.find("?=")?;
436    let text = &rest[..end];
437    if text.contains(char::is_whitespace) {
438        return None;
439    }
440    let bytes = match enc {
441        "Q" | "q" => decode_qp(text.replace('_', " ").as_bytes()),
442        "B" | "b" => decode_base64(text.as_bytes()).ok()?,
443        _ => return None,
444    };
445    // RFC 2231 allows `charset*language`.
446    let charset = charset.split('*').next().unwrap_or(charset);
447    let encoding = encoding_rs::Encoding::for_label(charset.as_bytes())?;
448    let (decoded, _, _) = encoding.decode(&bytes);
449    Some((decoded.into_owned(), 2 + q1 + 1 + q2 + 1 + end + 2))
450}
451
452/// The charset a page names in its first kilobyte (`<meta charset=...>` or
453/// `content="...; charset=..."`), or a style sheet in its `@charset`.
454fn sniff_charset(data: &[u8], html: bool) -> Option<String> {
455    let head = &data[..data.len().min(1024)];
456    let text = String::from_utf8_lossy(head);
457    if !html {
458        let rest = text.strip_prefix("@charset \"")?;
459        return Some(rest[..rest.find('"')?].to_string());
460    }
461    let lower = text.to_ascii_lowercase();
462    let at = lower.find("charset=")? + "charset=".len();
463    let value = lower[at..].trim_start_matches(['"', '\'', ' ']);
464    let end = value.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))).unwrap_or(value.len());
465    (end > 0).then(|| value[..end].to_string())
466}
467
468/// A text part's characters: by its byte order mark, else its `charset`,
469/// else the charset it declares itself, else UTF-8 when it is valid UTF-8,
470/// else windows-1252 (as browsers read undeclared Latin text). Labels are
471/// WHATWG's, so `iso-8859-1` reads as windows-1252, as in browsers.
472pub fn decode_text(data: &[u8], charset: Option<&str>, html: bool) -> String {
473    if let Some((encoding, _)) = encoding_rs::Encoding::for_bom(data) {
474        return encoding.decode(data).0.into_owned();
475    }
476    let named = charset.map(str::to_string).or_else(|| sniff_charset(data, html));
477    let encoding = named.and_then(|l| encoding_rs::Encoding::for_label(l.trim().as_bytes())).unwrap_or_else(|| {
478        if std::str::from_utf8(data).is_ok() { encoding_rs::UTF_8 } else { encoding_rs::WINDOWS_1252 }
479    });
480    encoding.decode_without_bom_handling(data).0.into_owned()
481}
482
483struct Parser {
484    limits: Limits,
485    total: u64,
486    parts: Vec<Part>,
487}
488
489impl Parser {
490    /// Reads one entity (`data` with its own headers) into `self.parts`,
491    /// recursing into multiparts.
492    fn entity(&mut self, data: &[u8], depth: usize) -> Result<(), MhtmlError> {
493        let (head, body) = split_head(data);
494        let headers = parse_headers(head);
495        self.entity_with(&headers, body, depth)
496    }
497
498    fn entity_with(&mut self, headers: &Headers, body: &[u8], depth: usize) -> Result<(), MhtmlError> {
499        let (ctype, params) = parse_params(headers.get("content-type").unwrap_or("text/plain"));
500        if ctype.starts_with("multipart/") {
501            if depth >= MAX_DEPTH {
502                return Err(MhtmlError::TooDeep);
503            }
504            let boundary = param(&params, "boundary").filter(|b| !b.is_empty()).ok_or(MhtmlError::NotArchive)?;
505            let pieces = split_multipart(body, boundary).ok_or(MhtmlError::NotArchive)?;
506            for piece in pieces {
507                self.entity(piece, depth + 1)?;
508            }
509            return Ok(());
510        }
511        if self.parts.len() >= self.limits.max_parts {
512            return Err(MhtmlError::TooManyParts(self.limits.max_parts));
513        }
514        let index = self.parts.len();
515        let cte = headers.get("content-transfer-encoding").unwrap_or("").trim().to_ascii_lowercase();
516        let ctype = if ctype.contains('/') { ctype } else { "text/plain".to_string() };
517        let decoded = match cte.as_str() {
518            "base64" => decode_base64(body).map_err(|e| MhtmlError::BadPart(index, e))?,
519            "quoted-printable" => decode_qp(body),
520            _ => body.to_vec(),
521        };
522        let decoded = if ctype.starts_with("text/") { fix_cr(decoded) } else { decoded };
523        self.total += decoded.len() as u64;
524        if self.total > self.limits.max_total {
525            return Err(MhtmlError::TooLarge(self.limits.max_total));
526        }
527        let location = headers.get("content-location").map(|v| decode_words(v).trim().trim_matches(['<', '>']).trim().to_string()).filter(|v| !v.is_empty());
528        let content_id = headers.get("content-id").map(|v| v.trim().trim_matches(['<', '>']).trim().to_string()).filter(|v| !v.is_empty());
529        self.parts.push(Part { content_type: ctype, charset: param(&params, "charset").map(str::to_string), location, content_id, body: decoded });
530        Ok(())
531    }
532}
533
534/// Reads an archive. A file whose top header isn't a multipart but is a
535/// single HTML entity (some writers do this) is an archive of one page.
536pub fn parse(data: &[u8], limits: Limits) -> Result<Archive, MhtmlError> {
537    let (head, body) = split_head(data);
538    let headers = parse_headers(head);
539    let Some(ct) = headers.get("content-type") else { return Err(MhtmlError::NotArchive) };
540    let (ctype, params) = parse_params(ct);
541    if !ctype.starts_with("multipart/") && !matches!(ctype.as_str(), "text/html" | "application/xhtml+xml") {
542        return Err(MhtmlError::NotArchive);
543    }
544    let mut parser = Parser { limits, total: 0, parts: Vec::new() };
545    parser.entity_with(&headers, body, 0)?;
546    let parts = parser.parts;
547    if parts.is_empty() {
548        return Err(MhtmlError::Empty);
549    }
550    let start = param(&params, "start").map(|s| s.trim().trim_matches(['<', '>']).to_string());
551    let root = start
552        .and_then(|s| parts.iter().position(|p| p.content_id.as_deref() == Some(s.as_str()) || p.location.as_deref() == Some(s.as_str())))
553        .or_else(|| parts.iter().position(Part::is_html))
554        .unwrap_or(0);
555    let subject = headers.get("subject").map(|s| decode_words(s).trim().to_string()).filter(|s| !s.is_empty());
556    let address = headers
557        .get("snapshot-content-location")
558        .map(|s| decode_words(s).trim().to_string())
559        .or_else(|| parts[root].location.clone())
560        .filter(|a| {
561            let l = a.to_ascii_lowercase();
562            l.starts_with("http://") || l.starts_with("https://")
563        });
564    Ok(Archive { parts, root, subject, address })
565}
566
567/// What [`unpack`] made.
568#[derive(Debug, Clone, PartialEq, Eq)]
569pub struct Unpacked {
570    /// The root page, `index.html` in the folder.
571    pub root: PathBuf,
572    /// Every file written, the root first.
573    pub files: Vec<PathBuf>,
574    /// The page title (`Subject`) and address, from the archive.
575    pub subject: Option<String>,
576    pub address: Option<String>,
577}
578
579/// The file extension for a MIME type, when it is one browsers decide by
580/// extension when loading from disk.
581fn ext_for(ctype: &str) -> Option<&'static str> {
582    Some(match ctype {
583        "text/html" | "application/xhtml+xml" => "html",
584        "text/css" => "css",
585        "text/javascript" | "application/javascript" | "application/x-javascript" => "js",
586        "image/png" => "png",
587        "image/jpeg" | "image/jpg" | "image/pjpeg" => "jpg",
588        "image/gif" => "gif",
589        "image/webp" => "webp",
590        "image/avif" => "avif",
591        "image/bmp" => "bmp",
592        "image/svg+xml" => "svg",
593        "image/x-icon" | "image/vnd.microsoft.icon" => "ico",
594        "image/tiff" => "tif",
595        "font/woff" | "application/font-woff" => "woff",
596        "font/woff2" => "woff2",
597        "font/ttf" | "application/x-font-ttf" => "ttf",
598        "font/otf" => "otf",
599        "text/plain" => "txt",
600        "application/json" => "json",
601        "audio/mpeg" => "mp3",
602        "video/mp4" => "mp4",
603        "video/webm" => "webm",
604        _ => return None,
605    })
606}
607
608/// Percent-decodes `s` (invalid escapes kept), reading the bytes as UTF-8.
609pub fn percent_decode(s: &str) -> String {
610    let b = s.as_bytes();
611    let mut out = Vec::with_capacity(b.len());
612    let mut i = 0;
613    while i < b.len() {
614        if b[i] == b'%'
615            && let (Some(h), Some(l)) = (b.get(i + 1).copied().and_then(hex), b.get(i + 2).copied().and_then(hex))
616        {
617            out.push(h << 4 | l);
618            i += 3;
619            continue;
620        }
621        out.push(b[i]);
622        i += 1;
623    }
624    String::from_utf8_lossy(&out).into_owned()
625}
626
627/// Percent-encodes a file name for use in a relative URL: everything but
628/// ASCII letters, digits and `-._~`.
629pub fn percent_encode(name: &str) -> String {
630    let mut out = String::with_capacity(name.len());
631    for b in name.bytes() {
632        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
633            out.push(b as char);
634        } else {
635            out.push_str(&format!("%{b:02X}"));
636        }
637    }
638    out
639}
640
641/// A safe file name from what an archive suggests: letters, digits, `-`,
642/// `_` and `.` kept, everything else `_`, no leading dot, at most 60
643/// characters.
644fn sanitize(name: &str) -> String {
645    let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '_' }).collect();
646    while s.starts_with('.') {
647        s.remove(0);
648    }
649    s.chars().take(60).collect()
650}
651
652/// The file name of part `index`: a number to keep names unique, then the
653/// last path segment of its location (or its content id), with the
654/// extension its type needs.
655fn file_name(index: usize, part: &Part) -> String {
656    let from_location = part.location.as_deref().and_then(|l| {
657        let l = l.split(['?', '#']).next().unwrap_or(l);
658        let l = l.strip_prefix("cid:").map_or(l, |c| c.split('@').next().unwrap_or(c));
659        let last = l.rsplit(['/', '\\']).find(|s| !s.is_empty())?;
660        let s = sanitize(&percent_decode(last));
661        (!s.is_empty()).then_some(s)
662    });
663    let base = from_location
664        .or_else(|| part.content_id.as_deref().map(|c| sanitize(c.split('@').next().unwrap_or(c))).filter(|s| !s.is_empty()))
665        .unwrap_or_else(|| "part".to_string());
666    let (stem, ext) = match base.rsplit_once('.') {
667        Some((s, e)) if !s.is_empty() && !e.is_empty() && e.len() <= 5 => (s.to_string(), Some(e.to_ascii_lowercase())),
668        _ => (base.clone(), None),
669    };
670    let ext = match (ext_for(&part.content_type), ext) {
671        (Some("jpg"), Some(e)) if e == "jpeg" || e == "jpe" => e,
672        (Some("html"), Some(e)) if e == "htm" || e == "xhtml" => "html".to_string(),
673        (Some(want), _) => want.to_string(),
674        (None, Some(e)) => e,
675        (None, None) => "bin".to_string(),
676    };
677    format!("{index}-{stem}.{ext}")
678}
679
680/// `dir/name`, refused unless `name` is a single plain file name.
681pub fn safe_join(dir: &Path, name: &str) -> Result<PathBuf, MhtmlError> {
682    let p = Path::new(name);
683    let mut comps = p.components();
684    let ok = matches!(comps.next(), Some(Component::Normal(c)) if c == p.as_os_str())
685        && comps.next().is_none()
686        && !name.contains(['/', '\\', '\0'])
687        && !name.is_empty();
688    if ok { Ok(dir.join(name)) } else { Err(MhtmlError::UnsafeName(name.to_string())) }
689}
690
691/// The scheme of `url`, lower-cased, when it has one.
692fn scheme(url: &str) -> Option<String> {
693    let colon = url.find(':')?;
694    let s = &url[..colon];
695    let mut chars = s.chars();
696    let first = chars.next()?;
697    (first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) && s.len() > 1).then(|| s.to_ascii_lowercase())
698}
699
700/// Removes `.` and `..` segments from a URL path (RFC 3986, 5.2.4).
701fn remove_dots(path: &str) -> String {
702    let mut out: Vec<&str> = Vec::new();
703    let segments: Vec<&str> = path.split('/').collect();
704    let n = segments.len();
705    for (i, seg) in segments.iter().enumerate() {
706        match *seg {
707            "." => {
708                if i == n - 1 {
709                    out.push("");
710                }
711            }
712            ".." => {
713                if out.len() > 1 {
714                    out.pop();
715                }
716                if i == n - 1 {
717                    out.push("");
718                }
719            }
720            s => out.push(s),
721        }
722    }
723    let joined = out.join("/");
724    if path.starts_with('/') && !joined.starts_with('/') { format!("/{joined}") } else { joined }
725}
726
727/// Resolves `reference` against `base` (RFC 3986, 5.2, simplified to the
728/// shapes archives use). `None` when the base has no scheme.
729pub fn resolve(base: &str, reference: &str) -> Option<String> {
730    if scheme(reference).is_some() {
731        return Some(reference.to_string());
732    }
733    let base_scheme = scheme(base)?;
734    let after = &base[base_scheme.len() + 1..];
735    let (authority, rest) = match after.strip_prefix("//") {
736        Some(a) => {
737            let end = a.find(['/', '?', '#']).unwrap_or(a.len());
738            (Some(&a[..end]), &a[end..])
739        }
740        None => (None, after),
741    };
742    let rest = rest.split('#').next().unwrap_or(rest);
743    let (path, query) = rest.split_once('?').map_or((rest, None), |(p, q)| (p, Some(q)));
744    let prefix = match authority {
745        Some(a) => format!("{base_scheme}://{a}"),
746        None => format!("{base_scheme}:"),
747    };
748    if let Some(net) = reference.strip_prefix("//") {
749        return Some(format!("{base_scheme}://{net}"));
750    }
751    if reference.starts_with('/') {
752        let (p, q) = reference.split_once('?').map_or((reference, None), |(p, q)| (p, Some(q)));
753        return Some(format!("{prefix}{}{}", remove_dots(p), q.map(|q| format!("?{q}")).unwrap_or_default()));
754    }
755    if reference.starts_with('?') {
756        return Some(format!("{prefix}{path}{reference}"));
757    }
758    if reference.is_empty() {
759        return Some(format!("{prefix}{path}{}", query.map(|q| format!("?{q}")).unwrap_or_default()));
760    }
761    let dir = match path.rfind('/') {
762        Some(i) => &path[..=i],
763        None if authority.is_some() => "/",
764        None => "",
765    };
766    let (p, q) = reference.split_once('?').map_or((reference, None), |(p, q)| (p, Some(q)));
767    Some(format!("{prefix}{}{}", remove_dots(&format!("{dir}{p}")), q.map(|q| format!("?{q}")).unwrap_or_default()))
768}
769
770/// Finds archive parts by the addresses pages use for them.
771struct Lookup {
772    by_location: HashMap<String, usize>,
773    by_cid: HashMap<String, usize>,
774    names: Vec<String>,
775}
776
777impl Lookup {
778    fn new(archive: &Archive, names: Vec<String>) -> Lookup {
779        let root_base = archive.parts[archive.root].location.clone();
780        let mut by_location = HashMap::new();
781        let mut by_cid = HashMap::new();
782        for (i, p) in archive.parts.iter().enumerate() {
783            if let Some(l) = &p.location {
784                by_location.entry(l.clone()).or_insert(i);
785                by_location.entry(percent_decode(l)).or_insert(i);
786                if let Some(abs) = root_base.as_deref().and_then(|b| resolve(b, l)) {
787                    by_location.entry(abs).or_insert(i);
788                }
789            }
790            if let Some(c) = &p.content_id {
791                by_cid.entry(c.clone()).or_insert(i);
792            }
793        }
794        Lookup { by_location, by_cid, names }
795    }
796
797    /// The part `reference` (no fragment, entities already decoded) names,
798    /// read against `base`.
799    fn find(&self, reference: &str, base: Option<&str>) -> Option<usize> {
800        let r = reference.trim();
801        if r.is_empty() {
802            return None;
803        }
804        if r.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("cid:")) && r.len() > 4 {
805            let id = percent_decode(&r[4..]);
806            return self.by_cid.get(&id).or_else(|| self.by_location.get(r)).copied();
807        }
808        if let Some(&i) = self.by_location.get(r) {
809            return Some(i);
810        }
811        let abs = base.and_then(|b| resolve(b, r))?;
812        self.by_location.get(&abs).or_else(|| self.by_location.get(&percent_decode(&abs))).copied()
813    }
814
815    /// The new reference for `reference` (entities decoded), keeping its
816    /// fragment; `None` to leave it alone.
817    fn rewrite(&self, reference: &str, base: Option<&str>) -> Option<String> {
818        let t = reference.trim();
819        if t.is_empty() || t.starts_with('#') {
820            return None;
821        }
822        let lower = t.get(..11).unwrap_or(t).to_ascii_lowercase();
823        if lower.starts_with("data:") || lower.starts_with("javascript:") || lower.starts_with("about:") || lower.starts_with("blob:") || lower.starts_with("mailto:") {
824            return None;
825        }
826        let without = t.split('#').next().unwrap_or(t);
827        let i = self.find(without, base)?;
828        let fragment = t.find('#').map_or("", |at| &t[at..]);
829        Some(format!("{}{fragment}", percent_encode(&self.names[i])))
830    }
831}
832
833/// Decodes the character references that matter in URLs and inline styles.
834fn decode_entities(s: &str) -> String {
835    if !s.contains('&') {
836        return s.to_string();
837    }
838    s.replace("&quot;", "\"")
839        .replace("&#34;", "\"")
840        .replace("&apos;", "'")
841        .replace("&#39;", "'")
842        .replace("&#x27;", "'")
843        .replace("&lt;", "<")
844        .replace("&gt;", ">")
845        .replace("&#38;", "&")
846        .replace("&#x26;", "&")
847        .replace("&amp;", "&")
848}
849
850/// Rewrites the `url(...)` and `@import "..."` references in a style
851/// sheet, read against `base`.
852fn rewrite_css(css: &str, base: Option<&str>, lookup: &Lookup) -> String {
853    let mut out = String::with_capacity(css.len());
854    let bytes = css.as_bytes();
855    let mut i = 0;
856    let mut copied = 0;
857    while i < bytes.len() {
858        // Comments are copied as they are.
859        if bytes[i..].starts_with(b"/*") {
860            i = css[i + 2..].find("*/").map_or(bytes.len(), |e| i + 2 + e + 2);
861            continue;
862        }
863        let url = bytes.len() - i >= 4 && bytes[i..i + 4].eq_ignore_ascii_case(b"url(");
864        let import = bytes.len() - i >= 7 && bytes[i..i + 7].eq_ignore_ascii_case(b"@import");
865        if !url && !import {
866            i += 1;
867            continue;
868        }
869        let mut j = i + if url { 4 } else { 7 };
870        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
871            j += 1;
872        }
873        let quote = bytes.get(j).copied().filter(|&q| q == b'"' || q == b'\'');
874        let (start, end) = match quote {
875            Some(q) => {
876                let s = j + 1;
877                let Some(e) = css[s..].find(q as char).map(|e| s + e) else { break };
878                (s, e)
879            }
880            None if url => {
881                let s = j;
882                let Some(e) = css[s..].find(')').map(|e| s + e) else { break };
883                let mut e2 = e;
884                while e2 > s && bytes[e2 - 1].is_ascii_whitespace() {
885                    e2 -= 1;
886                }
887                (s, e2)
888            }
889            // `@import url(...)` is handled when `url(` is reached.
890            None => {
891                i = j;
892                continue;
893            }
894        };
895        let raw = &css[start..end];
896        if let Some(new) = lookup.rewrite(raw, base) {
897            out.push_str(&css[copied..start]);
898            out.push_str(&new);
899            copied = end;
900        }
901        i = end + 1;
902    }
903    out.push_str(&css[copied..]);
904    out
905}
906
907/// Attributes that hold one URL.
908const URL_ATTRS: [&str; 8] = ["src", "href", "poster", "background", "data", "xlink:href", "action", "longdesc"];
909
910/// The meta elements put at the top of every unpacked page.
911fn injected_head(policy: Policy) -> String {
912    match policy {
913        Policy::Offline => format!("<meta charset=\"utf-8\"><meta http-equiv=\"Content-Security-Policy\" content=\"{CSP}\">"),
914        Policy::None => "<meta charset=\"utf-8\">".to_string(),
915    }
916}
917
918/// A tag's attribute: its lower-cased name and its value's byte span in
919/// the page (`None` for an attribute without a value), with its quote.
920struct Attr {
921    name: String,
922    value: Option<(usize, usize)>,
923    quote: Option<char>,
924}
925
926/// Reads the tag starting at `start` (just after its `<`): its lower-cased
927/// name, attributes and the index just past its `>`.
928fn read_tag(html: &str, start: usize) -> (String, Vec<Attr>, usize) {
929    let b = html.as_bytes();
930    let mut i = start;
931    while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'>' && b[i] != b'/' {
932        i += 1;
933    }
934    let name = html[start..i].to_ascii_lowercase();
935    let mut attrs = Vec::new();
936    loop {
937        while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b'/') {
938            i += 1;
939        }
940        if i >= b.len() {
941            return (name, attrs, b.len());
942        }
943        if b[i] == b'>' {
944            return (name, attrs, i + 1);
945        }
946        let ns = i;
947        while i < b.len() && !b[i].is_ascii_whitespace() && !matches!(b[i], b'=' | b'>') && !(b[i] == b'/' && i > ns) {
948            i += 1;
949        }
950        if i == ns {
951            i += 1;
952            continue;
953        }
954        let aname = html[ns..i].to_ascii_lowercase();
955        let mut j = i;
956        while j < b.len() && b[j].is_ascii_whitespace() {
957            j += 1;
958        }
959        if j < b.len() && b[j] == b'=' {
960            j += 1;
961            while j < b.len() && b[j].is_ascii_whitespace() {
962                j += 1;
963            }
964            if j < b.len() && (b[j] == b'"' || b[j] == b'\'') {
965                let q = b[j];
966                let vs = j + 1;
967                let ve = html[vs..].find(q as char).map_or(b.len(), |e| vs + e);
968                attrs.push(Attr { name: aname, value: Some((vs, ve)), quote: Some(q as char) });
969                i = (ve + 1).min(b.len());
970            } else {
971                let vs = j;
972                while j < b.len() && !b[j].is_ascii_whitespace() && b[j] != b'>' {
973                    j += 1;
974                }
975                attrs.push(Attr { name: aname, value: Some((vs, j)), quote: None });
976                i = j;
977            }
978        } else {
979            attrs.push(Attr { name: aname, value: None, quote: None });
980        }
981    }
982}
983
984/// The first index from `from` where `needle` (ASCII, lower-case) occurs
985/// in `hay`, ignoring ASCII case.
986fn find_ci(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
987    if needle.is_empty() || hay.len() < needle.len() {
988        return None;
989    }
990    (from..=hay.len() - needle.len()).find(|&i| hay[i..i + needle.len()].eq_ignore_ascii_case(needle))
991}
992
993/// Escapes a new attribute value for its quote.
994fn escape_attr(v: &str, quote: Option<char>) -> String {
995    let v = v.replace('&', "&amp;");
996    match quote {
997        Some('\'') => v.replace('\'', "&#39;"),
998        _ => v.replace('"', "&quot;"),
999    }
1000}
1001
1002/// Rewrites a `srcset` value (`url 1x, url 2x`).
1003fn rewrite_srcset(value: &str, base: Option<&str>, lookup: &Lookup) -> Option<String> {
1004    let mut changed = false;
1005    let items: Vec<String> = value
1006        .split(',')
1007        .map(|cand| {
1008            let c = cand.trim();
1009            let (url, desc) = c.split_once(char::is_whitespace).map_or((c, ""), |(u, d)| (u, d.trim()));
1010            match lookup.rewrite(url, base) {
1011                Some(n) => {
1012                    changed = true;
1013                    if desc.is_empty() { n } else { format!("{n} {desc}") }
1014                }
1015                None => c.to_string(),
1016            }
1017        })
1018        .collect();
1019    changed.then(|| items.join(", "))
1020}
1021
1022/// Rewrites a page's references to archive parts and puts the charset and
1023/// security policy at the top of its head. `base` is the part's own
1024/// location; a `<base href>` replaces it for what follows and is pointed
1025/// at the folder.
1026fn rewrite_html(html: &str, base: Option<&str>, lookup: &Lookup, policy: Policy) -> String {
1027    let mut base = base.map(str::to_string);
1028    let b = html.as_bytes();
1029    let mut out = String::with_capacity(html.len() + 512);
1030    let mut copied = 0;
1031    let mut injected = false;
1032    let mut i = 0;
1033    while i < b.len() {
1034        if b[i] != b'<' {
1035            i += 1;
1036            continue;
1037        }
1038        if html[i..].starts_with("<!--") {
1039            i = html[i + 4..].find("-->").map_or(b.len(), |e| i + 4 + e + 3);
1040            continue;
1041        }
1042        if !b.get(i + 1).is_some_and(u8::is_ascii_alphabetic) {
1043            i += 1;
1044            continue;
1045        }
1046        let (name, attrs, end) = read_tag(html, i + 1);
1047        // Replacements inside this tag, as (start, end, text).
1048        let mut edits: Vec<(usize, usize, String)> = Vec::new();
1049        for a in &attrs {
1050            let Some((vs, ve)) = a.value else { continue };
1051            let raw = &html[vs..ve];
1052            let decoded = decode_entities(raw);
1053            let new = if name == "base" && a.name == "href" {
1054                if let Some(abs) = match base.as_deref() {
1055                    Some(bs) => resolve(bs, decoded.trim()),
1056                    None => scheme(decoded.trim()).map(|_| decoded.trim().to_string()),
1057                } {
1058                    base = Some(abs);
1059                }
1060                Some("./".to_string())
1061            } else if URL_ATTRS.contains(&a.name.as_str()) {
1062                lookup.rewrite(&decoded, base.as_deref())
1063            } else if a.name == "srcset" || a.name == "imagesrcset" {
1064                rewrite_srcset(&decoded, base.as_deref(), lookup)
1065            } else if a.name == "style" {
1066                let css = rewrite_css(&decoded, base.as_deref(), lookup);
1067                (css != decoded).then_some(css)
1068            } else {
1069                None
1070            };
1071            if let Some(new) = new {
1072                let text = match a.quote {
1073                    Some(_) => escape_attr(&new, a.quote),
1074                    None => format!("\"{}\"", escape_attr(&new, Some('"'))),
1075                };
1076                edits.push((vs, ve, text));
1077            }
1078        }
1079        if !edits.is_empty() {
1080            for (s, e, text) in edits {
1081                out.push_str(&html[copied..s]);
1082                out.push_str(&text);
1083                copied = e;
1084            }
1085        }
1086        let mut next = end;
1087        if !injected && name == "head" {
1088            out.push_str(&html[copied..end]);
1089            out.push_str(&injected_head(policy));
1090            copied = end;
1091            injected = true;
1092        }
1093        if matches!(name.as_str(), "style" | "script" | "textarea" | "title") {
1094            let close = format!("</{name}");
1095            let stop = find_ci(b, end, close.as_bytes()).unwrap_or(b.len());
1096            if name == "style" {
1097                out.push_str(&html[copied..end]);
1098                out.push_str(&rewrite_css(&html[end..stop], base.as_deref(), lookup));
1099                copied = stop;
1100            }
1101            next = stop;
1102        }
1103        i = next.max(i + 1);
1104    }
1105    out.push_str(&html[copied..]);
1106    if injected {
1107        return out;
1108    }
1109    // No <head>: after the doctype (before it would switch to quirks mode).
1110    let mut n = out.len().min(512);
1111    while !out.is_char_boundary(n) {
1112        n -= 1;
1113    }
1114    let lower = out[..n].to_ascii_lowercase();
1115    let at = lower.find("<!doctype").and_then(|d| out[d..].find('>').map(|e| d + e + 1)).unwrap_or(0);
1116    out.insert_str(at, &injected_head(policy));
1117    out
1118}
1119
1120/// Unpacks the archive in `data` into `dir`, which must exist and should
1121/// be empty: the root page as `index.html`, every other part under a name
1122/// falcon makes, HTML and CSS rewritten to point at each other's files.
1123pub fn unpack(data: &[u8], dir: &Path, limits: Limits, policy: Policy) -> Result<Unpacked, MhtmlError> {
1124    let archive = parse(data, limits)?;
1125    let names: Vec<String> = archive.parts.iter().enumerate().map(|(i, p)| if i == archive.root { ROOT_NAME.to_string() } else { file_name(i, p) }).collect();
1126    let lookup = Lookup::new(&archive, names);
1127    let root_base = archive.parts[archive.root].location.clone();
1128    let mut files = Vec::with_capacity(archive.parts.len());
1129    let mut written: u64 = 0;
1130    let mut order: Vec<usize> = (0..archive.parts.len()).collect();
1131    order.swap(0, archive.root);
1132    for i in order {
1133        let part = &archive.parts[i];
1134        let path = safe_join(dir, &lookup.names[i])?;
1135        let base = part.location.as_deref().filter(|l| scheme(l).is_some_and(|s| s != "cid")).or(root_base.as_deref());
1136        let bytes = if part.is_html() || part.is_css() {
1137            let text = decode_text(&part.body, part.charset.as_deref(), part.is_html());
1138            let text = if part.is_html() { rewrite_html(&text, base, &lookup, policy) } else { rewrite_css(&text, base, &lookup) };
1139            let mut v = Vec::with_capacity(text.len() + 3);
1140            v.extend_from_slice(b"\xEF\xBB\xBF");
1141            v.extend_from_slice(text.as_bytes());
1142            v
1143        } else {
1144            part.body.clone()
1145        };
1146        written += bytes.len() as u64;
1147        // Rewriting adds a little to each page; allow for it.
1148        if written > limits.max_total.saturating_add(limits.max_total / 8) {
1149            return Err(MhtmlError::TooLarge(limits.max_total));
1150        }
1151        fs::write(&path, bytes)?;
1152        files.push(path);
1153    }
1154    Ok(Unpacked { root: dir.join(ROOT_NAME), files, subject: archive.subject, address: archive.address })
1155}
1156
1157/// Unpacks the archive file at `path` into `dir`, refusing a file larger
1158/// than twice the size limit (base64 makes parts a third larger).
1159pub fn unpack_file(path: &Path, dir: &Path, limits: Limits, policy: Policy) -> Result<Unpacked, MhtmlError> {
1160    let len = fs::metadata(path)?.len();
1161    if len > limits.max_total.saturating_mul(2) {
1162        return Err(MhtmlError::TooLarge(limits.max_total));
1163    }
1164    unpack(&fs::read(path)?, dir, limits, policy)
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    #[test]
1172    fn chrome_sample_unpacks() {
1173        let data = include_bytes!("../testdata/mhtml/chrome.mhtml");
1174        let dir = tempfile::tempdir().unwrap();
1175        let u = unpack(data, dir.path(), Limits::default(), Policy::Offline).unwrap();
1176        let html = fs::read_to_string(&u.root).unwrap();
1177        assert!(html.starts_with('\u{FEFF}'), "a byte order mark leads");
1178        assert!(html.contains("<head><meta charset=\"utf-8\"><meta http-equiv=\"Content-Security-Policy\""));
1179        assert!(html.contains("<h1>Café archive</h1>"), "quoted-printable UTF-8 decoded");
1180        assert!(html.contains("href=\"1-css-1.css\""), "cid: stylesheet rewritten");
1181        assert!(html.contains("src=\"2-red.png\""), "full-URL image with a query rewritten");
1182        assert_eq!(u.subject.as_deref(), Some("Café page"));
1183        assert_eq!(u.address.as_deref(), Some("https://example.com/page"));
1184        let names: Vec<String> = u.files.iter().map(|f| f.file_name().unwrap().to_string_lossy().into_owned()).collect();
1185        assert_eq!(names, ["index.html", "1-css-1.css", "2-red.png"]);
1186        let png = fs::read(dir.path().join("2-red.png")).unwrap();
1187        assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
1188        assert!(image::load_from_memory(&png).is_ok(), "base64 decoded intact");
1189    }
1190
1191    // ---- helpers ----
1192
1193    const CHROME: &[u8] = include_bytes!("../testdata/mhtml/chrome.mhtml");
1194
1195    /// One entity: header lines (no trailing break), a blank line, the body.
1196    fn part(headers: &str, body: &[u8]) -> Vec<u8> {
1197        let mut v = headers.replace('\n', "\r\n").into_bytes();
1198        v.extend_from_slice(b"\r\n\r\n");
1199        v.extend_from_slice(body);
1200        v
1201    }
1202
1203    fn multipart_body(boundary: &str, parts: &[Vec<u8>]) -> Vec<u8> {
1204        let mut v = Vec::new();
1205        for p in parts {
1206            v.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
1207            v.extend_from_slice(p);
1208            v.extend_from_slice(b"\r\n");
1209        }
1210        v.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
1211        v
1212    }
1213
1214    /// A whole file: `top` headers, then the parts between `boundary` lines.
1215    fn wrap(top: &str, boundary: &str, parts: &[Vec<u8>]) -> Vec<u8> {
1216        part(top, &multipart_body(boundary, parts))
1217    }
1218
1219    fn archive(parts: &[Vec<u8>]) -> Vec<u8> {
1220        wrap("MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"BND\"", "BND", parts)
1221    }
1222
1223    fn html_part(location: &str, body: &str) -> Vec<u8> {
1224        part(&format!("Content-Type: text/html; charset=utf-8\nContent-Location: {location}"), body.as_bytes())
1225    }
1226
1227    fn png_part(location: &str) -> Vec<u8> {
1228        part(&format!("Content-Type: image/png\nContent-Location: {location}"), b"PNGDATA")
1229    }
1230
1231    fn unpack_to(data: &[u8], policy: Policy) -> (tempfile::TempDir, Unpacked) {
1232        let dir = tempfile::tempdir().unwrap();
1233        let u = unpack(data, dir.path(), Limits::default(), policy).unwrap();
1234        (dir, u)
1235    }
1236
1237    /// A file's text without its byte order mark.
1238    fn read_text(dir: &Path, name: &str) -> String {
1239        let bytes = fs::read(dir.join(name)).unwrap();
1240        assert!(bytes.starts_with(b"\xEF\xBB\xBF"), "{name} leads with a byte order mark");
1241        String::from_utf8(bytes[3..].to_vec()).unwrap()
1242    }
1243
1244    fn lcg(state: &mut u64) -> u8 {
1245        *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1246        (*state >> 33) as u8
1247    }
1248
1249    // ---- transfer encodings ----
1250
1251    #[test]
1252    fn base64_with_breaks_missing_padding_and_stray_characters() {
1253        let original: Vec<u8> = (0..=255u8).collect();
1254        let encoded = base64::engine::general_purpose::STANDARD.encode(&original);
1255        let unpadded = encoded.trim_end_matches('=');
1256        let mut messy = String::new();
1257        for (i, c) in unpadded.chars().enumerate() {
1258            if i % 76 == 0 && i > 0 {
1259                messy.push_str("\r\n");
1260            }
1261            if i == 10 {
1262                messy.push_str(" *!\t");
1263            }
1264            messy.push(c);
1265        }
1266        messy.push_str("\r\n");
1267        let data = archive(&[part("Content-Type: application/octet-stream\nContent-Transfer-Encoding: base64", messy.as_bytes())]);
1268        let a = parse(&data, Limits::default()).unwrap();
1269        assert_eq!(a.parts[0].body, original);
1270    }
1271
1272    #[test]
1273    fn quoted_printable_escapes_and_soft_breaks() {
1274        assert_eq!(decode_qp(b"a=3Db=3db=C3=a9"), "a=b=bé".as_bytes());
1275        // Soft breaks: CRLF, LF, and trailing blanks before the break.
1276        assert_eq!(decode_qp(b"ab=\r\ncd=\nef=  \r\ngh=\t\nij"), b"abcdefghij");
1277        // A lone `=` that starts neither an escape nor a break stays.
1278        assert_eq!(decode_qp(b"a=zz b= c =g"), b"a=zz b= c =g");
1279        // An `=` at the very end is a soft break with nothing after it.
1280        assert_eq!(decode_qp(b"x=="), b"x=");
1281    }
1282
1283    #[test]
1284    fn quoted_printable_cr_before_crlf_gives_one_crlf() {
1285        let data = archive(&[part("Content-Type: text/plain\nContent-Transfer-Encoding: quoted-printable", b"line1=0D\r\nline2=0D\r\n")]);
1286        let a = parse(&data, Limits::default()).unwrap();
1287        assert_eq!(a.parts[0].body, b"line1\r\nline2\r\n");
1288    }
1289
1290    // ---- structure ----
1291
1292    #[test]
1293    fn nested_multipart_flattens_and_root_is_the_html() {
1294        let inner = multipart_body("inner", &[part("Content-Type: text/plain", b"plain"), part("Content-Type: text/html", b"<p>html</p>")]);
1295        let nested = part("Content-Type: multipart/alternative; boundary=\"inner\"", &inner);
1296        let data = archive(&[nested, part("Content-Type: image/png", b"png")]);
1297        let a = parse(&data, Limits::default()).unwrap();
1298        let types: Vec<&str> = a.parts.iter().map(|p| p.content_type.as_str()).collect();
1299        assert_eq!(types, ["text/plain", "text/html", "image/png"]);
1300        assert_eq!(a.root, 1);
1301    }
1302
1303    #[test]
1304    fn start_parameter_picks_the_root_by_content_id() {
1305        let parts = [
1306            part("Content-Type: text/html\nContent-ID: <first@x>", b"first"),
1307            part("Content-Type: text/html\nContent-ID: <second@x>", b"second"),
1308        ];
1309        let plain = parse(&archive(&parts), Limits::default()).unwrap();
1310        assert_eq!(plain.root, 0, "without start the first html part");
1311        let data = wrap("Content-Type: multipart/related; start=\"<second@x>\"; boundary=\"BND\"", "BND", &parts);
1312        let a = parse(&data, Limits::default()).unwrap();
1313        assert_eq!(a.root, 1);
1314        assert_eq!(a.parts[a.root].body, b"second");
1315    }
1316
1317    // ---- text ----
1318
1319    /// Unpacks one html part with `ctype` header text and returns index.html.
1320    fn unpack_page(ctype: &str, body: &[u8]) -> String {
1321        let data = archive(&[part(&format!("Content-Type: {ctype}"), body)]);
1322        let (dir, _) = unpack_to(&data, Policy::None);
1323        read_text(dir.path(), "index.html")
1324    }
1325
1326    #[test]
1327    fn charset_utf8_page() {
1328        let html = unpack_page("text/html; charset=utf-8", "<p>é€</p>".as_bytes());
1329        assert!(html.contains("<p>é€</p>"));
1330    }
1331
1332    #[test]
1333    fn charset_windows_1252_page() {
1334        let html = unpack_page("text/html; charset=windows-1252", b"<p>\x80 \xE9 \x93q\x94</p>");
1335        assert!(html.contains("<p>€ é “q”</p>"), "{html}");
1336    }
1337
1338    #[test]
1339    fn charset_iso_8859_1_label_reads_as_windows_1252() {
1340        let html = unpack_page("text/html; charset=iso-8859-1", b"<p>\x80 \xE9 \x93q\x94</p>");
1341        assert!(html.contains("<p>€ é “q”</p>"), "{html}");
1342    }
1343
1344    #[test]
1345    fn charset_undeclared_invalid_utf8_falls_back_to_windows_1252() {
1346        let html = unpack_page("text/html", b"<p>caf\xE9 \x80</p>");
1347        assert!(html.contains("<p>café €</p>"), "{html}");
1348    }
1349
1350    #[test]
1351    fn charset_from_meta_when_mime_gives_none() {
1352        let html = unpack_page("text/html", b"<html><head><meta charset=\"windows-1252\"></head><body>\x93q\x94 \xE9</body></html>");
1353        assert!(html.contains("“q” é"), "{html}");
1354    }
1355
1356    #[test]
1357    fn encoded_words_in_headers() {
1358        assert_eq!(decode_words("=?utf-8?Q?Caf=C3=A9_au_lait?="), "Café au lait");
1359        assert_eq!(decode_words("=?utf-8?B?Q2Fmw6k=?="), "Café");
1360        assert_eq!(decode_words("=?iso-8859-1?Q?Caf=E9?="), "Café");
1361        // Blanks between two adjacent encoded words are dropped.
1362        assert_eq!(decode_words("=?utf-8?Q?Caf?= =?utf-8?Q?=C3=A9?="), "Café");
1363        // Text around a word is kept.
1364        assert_eq!(decode_words("pre =?utf-8?Q?x?= post"), "pre x post");
1365        // An unknown charset is left as written.
1366        assert_eq!(decode_words("=?x-nonsense?Q?abc?="), "=?x-nonsense?Q?abc?=");
1367    }
1368
1369    #[test]
1370    fn encoded_words_in_location_and_subject() {
1371        let data = wrap(
1372            "Subject: =?iso-8859-1?Q?Caf=E9_page?=\nContent-Type: multipart/related; boundary=\"BND\"",
1373            "BND",
1374            &[part("Content-Type: text/html\nContent-Location: =?utf-8?B?aHR0cHM6Ly9leGFtcGxlLmNvbS9hLmh0bWw=?=", b"x")],
1375        );
1376        let a = parse(&data, Limits::default()).unwrap();
1377        assert_eq!(a.subject.as_deref(), Some("Café page"));
1378        assert_eq!(a.parts[0].location.as_deref(), Some("https://example.com/a.html"));
1379        assert_eq!(a.address.as_deref(), Some("https://example.com/a.html"));
1380    }
1381
1382    // ---- reference rewriting ----
1383
1384    const SITE: &str = "https://example.com/dir/sub/page.html";
1385
1386    /// A page at `SITE` with the references the tests below look at, and
1387    /// the parts they point to (indexes in the comments).
1388    fn site() -> (tempfile::TempDir, Unpacked) {
1389        let root = r##"<!DOCTYPE html><html><head>
1390<link rel="stylesheet" href="../css/main.css">
1391<style>@import "c.css"; p { background: url(a.png) } i { background: url( 'a.png' ) }</style>
1392<script>var u = "a.png"; document.write('<img src="a.png">'); var c = "url(a.png)";</script>
1393</head><body>
1394<img id=rel src="a.png">
1395<img id=up src="../up.png">
1396<img id=abs src="/abs.png">
1397<img id=proto src="//cdn.example.com/x.png">
1398<img id=query src="q.png?v=2">
1399<a id=frag href="frag.html#sec">f</a>
1400<a id=amp href="a&amp;b.png">amp</a>
1401<img id=srcset srcset="s1.png 1x, s2.png 2x" src="s1.png">
1402<div id=inline style="background:url(&quot;a.png&quot;)"></div>
1403<img id=cid src="cid:logo@x">
1404<img id=missing src="missing.png">
1405<img id=remote src="https://example.com/nope.png">
1406<img id=data src="data:image/png;base64,AAAA">
1407<base href="https://example.com/other/">
1408<img id=based src="b.png">
1409</body></html>"##;
1410        let data = archive(&[
1411            html_part(SITE, root),                                                          // 0
1412            png_part("https://example.com/dir/sub/a.png"),                                  // 1
1413            png_part("https://example.com/dir/up.png"),                                     // 2
1414            png_part("https://example.com/abs.png"),                                        // 3
1415            png_part("https://cdn.example.com/x.png"),                                      // 4
1416            png_part("https://example.com/dir/sub/q.png?v=2"),                              // 5
1417            html_part("https://example.com/dir/sub/frag.html", "<p>f</p>"),                 // 6
1418            png_part("https://example.com/dir/sub/a&b.png"),                                // 7
1419            png_part("https://example.com/dir/sub/s1.png"),                                 // 8
1420            png_part("https://example.com/dir/sub/s2.png"),                                 // 9
1421            part("Content-Type: text/css\nContent-Location: https://example.com/dir/sub/c.css", b"p{}"), // 10
1422            part("Content-Type: text/css\nContent-Location: https://example.com/dir/css/main.css", b"div { background: url(../img/in.png) }"), // 11
1423            png_part("https://example.com/dir/img/in.png"),                                 // 12
1424            png_part("https://example.com/other/b.png"),                                    // 13
1425            part("Content-Type: image/png\nContent-ID: <logo@x>", b"PNGDATA"),              // 14
1426        ]);
1427        unpack_to(&data, Policy::None)
1428    }
1429
1430    #[test]
1431    fn rewrites_relative_absolute_and_protocol_relative_references() {
1432        let (dir, _) = site();
1433        let html = read_text(dir.path(), "index.html");
1434        assert!(html.contains("id=rel src=\"1-a.png\""), "{html}");
1435        assert!(html.contains("id=up src=\"2-up.png\""), "dot-dot path");
1436        assert!(html.contains("id=abs src=\"3-abs.png\""), "absolute path");
1437        assert!(html.contains("id=proto src=\"4-x.png\""), "protocol-relative");
1438        assert!(html.contains("<link rel=\"stylesheet\" href=\"11-main.css\">"));
1439        assert!(html.contains("id=cid src=\"14-logo.png\""), "cid: reference");
1440    }
1441
1442    #[test]
1443    fn rewrites_query_fragment_and_entities() {
1444        let (dir, _) = site();
1445        let html = read_text(dir.path(), "index.html");
1446        assert!(html.contains("id=query src=\"5-q.png\""), "query matched the part with the same query");
1447        assert!(html.contains("id=frag href=\"6-frag.html#sec\""), "fragment kept");
1448        assert!(html.contains("id=amp href=\"7-a_b.png\""), "&amp; in the href matched a & location");
1449    }
1450
1451    #[test]
1452    fn rewrites_srcset_with_descriptors_and_inline_style() {
1453        let (dir, _) = site();
1454        let html = read_text(dir.path(), "index.html");
1455        assert!(html.contains("srcset=\"8-s1.png 1x, 9-s2.png 2x\""), "{html}");
1456        assert!(html.contains("style=\"background:url(&quot;1-a.png&quot;)\""), "{html}");
1457    }
1458
1459    #[test]
1460    fn rewrites_style_blocks_with_url_and_import() {
1461        let (dir, _) = site();
1462        let html = read_text(dir.path(), "index.html");
1463        assert!(html.contains("@import \"10-c.css\";"), "{html}");
1464        assert!(html.contains("url(1-a.png)"), "unquoted url()");
1465        assert!(html.contains("url( '1-a.png' )"), "quoted url() with blanks");
1466    }
1467
1468    #[test]
1469    fn css_part_resolves_against_its_own_location() {
1470        let (dir, _) = site();
1471        let css = read_text(dir.path(), "11-main.css");
1472        assert!(css.contains("url(12-in.png)"), "{css}");
1473    }
1474
1475    #[test]
1476    fn base_href_changes_the_base_and_points_at_the_folder() {
1477        let (dir, _) = site();
1478        let html = read_text(dir.path(), "index.html");
1479        assert!(html.contains("<base href=\"./\">"), "{html}");
1480        assert!(html.contains("id=based src=\"13-b.png\""), "resolved against the new base");
1481    }
1482
1483    #[test]
1484    fn leaves_unknown_and_data_references_alone() {
1485        let (dir, _) = site();
1486        let html = read_text(dir.path(), "index.html");
1487        assert!(html.contains("id=missing src=\"missing.png\""));
1488        assert!(html.contains("id=remote src=\"https://example.com/nope.png\""));
1489        assert!(html.contains("id=data src=\"data:image/png;base64,AAAA\""));
1490    }
1491
1492    #[test]
1493    fn script_content_is_not_rewritten() {
1494        let (dir, _) = site();
1495        let html = read_text(dir.path(), "index.html");
1496        assert!(html.contains(r#"<script>var u = "a.png"; document.write('<img src="a.png">'); var c = "url(a.png)";</script>"#), "{html}");
1497    }
1498
1499    #[test]
1500    fn word_archive_rewrites_image_and_file_list() {
1501        let root = part(
1502            "Content-Type: text/html; charset=\"windows-1252\"\nContent-Transfer-Encoding: quoted-printable\nContent-Location: file:///C:/Users/x/doc.htm",
1503            b"<html><head><link rel=3DFile-List href=3D\"doc_files/filelist.xml\"></head><body><img src=3D\"doc_files/image001.png\"></body></html>",
1504        );
1505        let data = wrap(
1506            "MIME-Version: 1.0\nContent-Type: multipart/related; type=\"text/html\"; boundary=\"----=_NextPart_01\"",
1507            "----=_NextPart_01",
1508            &[
1509                root,
1510                png_part("file:///C:/Users/x/doc_files/image001.png"),
1511                part("Content-Type: text/xml\nContent-Location: file:///C:/Users/x/doc_files/filelist.xml", b"<xml/>"),
1512            ],
1513        );
1514        let (dir, u) = unpack_to(&data, Policy::None);
1515        assert_eq!(u.files.len(), 3);
1516        let html = read_text(dir.path(), "index.html");
1517        assert!(html.contains("href=\"2-filelist.xml\""), "{html}");
1518        assert!(html.contains("src=\"1-image001.png\""), "{html}");
1519    }
1520
1521    // ---- file names ----
1522
1523    #[test]
1524    fn hostile_locations_land_as_plain_files_in_the_folder() {
1525        let outer = tempfile::tempdir().unwrap();
1526        let out = outer.path().join("out");
1527        fs::create_dir(&out).unwrap();
1528        let locations: [&[u8]; 6] = [
1529            b"../../../etc/passwd",
1530            b"/etc/passwd",
1531            b"file:///etc/passwd",
1532            b"..\\..\\x.png",
1533            b"a\0b/c.png",
1534            b"x/y/z/../../w.png",
1535        ];
1536        let mut parts = vec![html_part("https://example.com/", "<p>hi</p>")];
1537        for loc in locations {
1538            let mut headers = b"Content-Type: image/png\r\nContent-Location: ".to_vec();
1539            headers.extend_from_slice(loc);
1540            headers.extend_from_slice(b"\r\n\r\nPNGDATA");
1541            parts.push(headers);
1542        }
1543        let data = archive(&parts);
1544        let u = unpack(&data, &out, Limits::default(), Policy::None).unwrap();
1545        assert_eq!(u.files.len(), parts.len());
1546        let mut count = 0;
1547        for entry in fs::read_dir(&out).unwrap() {
1548            let entry = entry.unwrap();
1549            assert!(entry.file_type().unwrap().is_file(), "{:?} is a plain file", entry.path());
1550            assert_eq!(entry.path().parent().unwrap(), out);
1551            count += 1;
1552        }
1553        assert_eq!(count, parts.len());
1554        for f in &u.files {
1555            assert_eq!(f.parent().unwrap(), out);
1556        }
1557        let siblings: Vec<_> = fs::read_dir(outer.path()).unwrap().map(|e| e.unwrap().file_name()).collect();
1558        assert_eq!(siblings, ["out"], "nothing written beside the folder");
1559    }
1560
1561    #[test]
1562    fn safe_join_accepts_only_plain_names() {
1563        let dir = Path::new("/some/dir");
1564        assert_eq!(safe_join(dir, "index.html").unwrap(), dir.join("index.html"));
1565        for bad in ["..", ".", "a/b", "/abs", "", "a\\b", "a\0b"] {
1566            assert!(matches!(safe_join(dir, bad), Err(MhtmlError::UnsafeName(_))), "{bad:?}");
1567        }
1568    }
1569
1570    #[test]
1571    fn percent_encoding_round_trips() {
1572        assert_eq!(percent_encode("a b&é.png"), "a%20b%26%C3%A9.png");
1573        assert_eq!(percent_decode("a%20b%26%C3%A9.png"), "a b&é.png");
1574        assert_eq!(percent_decode("100%zz%2"), "100%zz%2", "invalid escapes kept");
1575    }
1576
1577    #[test]
1578    fn resolve_follows_rfc_3986_for_common_shapes() {
1579        let base = "https://example.com/a/b/c.html?x=1";
1580        assert_eq!(resolve(base, "d.png").as_deref(), Some("https://example.com/a/b/d.png"));
1581        assert_eq!(resolve(base, "../d.png").as_deref(), Some("https://example.com/a/d.png"));
1582        assert_eq!(resolve(base, "/d.png").as_deref(), Some("https://example.com/d.png"));
1583        assert_eq!(resolve(base, "//other.org/d.png").as_deref(), Some("https://other.org/d.png"));
1584        assert_eq!(resolve(base, "?y=2").as_deref(), Some("https://example.com/a/b/c.html?y=2"));
1585        assert_eq!(resolve(base, "http://z/").as_deref(), Some("http://z/"));
1586        assert_eq!(resolve("not a url", "d.png"), None);
1587    }
1588
1589    // ---- limits ----
1590
1591    fn text_parts(n: usize, size: usize) -> Vec<u8> {
1592        let parts: Vec<Vec<u8>> = (0..n).map(|_| part("Content-Type: text/plain", &vec![b'x'; size])).collect();
1593        archive(&parts)
1594    }
1595
1596    #[test]
1597    fn total_size_cap() {
1598        let data = text_parts(2, 20);
1599        let limits = Limits { max_total: 30, ..Limits::default() };
1600        assert!(matches!(parse(&data, limits), Err(MhtmlError::TooLarge(30))));
1601        let dir = tempfile::tempdir().unwrap();
1602        assert!(matches!(unpack(&data, dir.path(), limits, Policy::None), Err(MhtmlError::TooLarge(_))));
1603        assert!(parse(&data, Limits { max_total: 40, ..Limits::default() }).is_ok());
1604    }
1605
1606    #[test]
1607    fn part_count_cap() {
1608        let data = text_parts(3, 1);
1609        let limits = Limits { max_parts: 2, ..Limits::default() };
1610        assert!(matches!(parse(&data, limits), Err(MhtmlError::TooManyParts(2))));
1611        assert_eq!(parse(&data, Limits { max_parts: 3, ..Limits::default() }).unwrap().parts.len(), 3);
1612    }
1613
1614    /// A file of `levels` multiparts inside each other around one text part.
1615    fn nested(levels: usize) -> Vec<u8> {
1616        let mut inner = part("Content-Type: text/plain", b"x");
1617        for k in (0..levels).rev() {
1618            let boundary = format!("lvl{k}x");
1619            inner = part(&format!("Content-Type: multipart/mixed; boundary=\"{boundary}\""), &multipart_body(&boundary, &[inner]));
1620        }
1621        inner
1622    }
1623
1624    #[test]
1625    fn nesting_depth_cap() {
1626        assert_eq!(parse(&nested(8), Limits::default()).unwrap().parts.len(), 1);
1627        assert!(matches!(parse(&nested(9), Limits::default()), Err(MhtmlError::TooDeep)));
1628        assert!(matches!(parse(&nested(12), Limits::default()), Err(MhtmlError::TooDeep)));
1629    }
1630
1631    // ---- malformed input ----
1632
1633    #[test]
1634    fn empty_and_headerless_input_is_an_error() {
1635        assert!(parse(b"", Limits::default()).is_err());
1636        assert!(parse(b"\r\n\r\n", Limits::default()).is_err());
1637        assert!(parse(b"just some text", Limits::default()).is_err());
1638    }
1639
1640    #[test]
1641    fn headers_only_is_an_error() {
1642        assert!(parse(b"Content-Type: multipart/related; boundary=\"B\"\r\n", Limits::default()).is_err());
1643        assert!(parse(b"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n", Limits::default()).is_err());
1644    }
1645
1646    #[test]
1647    fn missing_boundary_parameter_is_an_error() {
1648        let data = b"Content-Type: multipart/related\r\n\r\n--B\r\nContent-Type: text/html\r\n\r\nx\r\n--B--\r\n";
1649        assert!(matches!(parse(data, Limits::default()), Err(MhtmlError::NotArchive)));
1650    }
1651
1652    #[test]
1653    fn boundary_that_never_appears_is_an_error() {
1654        let data = b"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n--OTHER\r\nContent-Type: text/html\r\n\r\nx\r\n--OTHER--\r\n";
1655        assert!(parse(data, Limits::default()).is_err());
1656    }
1657
1658    #[test]
1659    fn missing_final_boundary_still_reads_the_last_part() {
1660        let data = b"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html\r\n\r\nfirst\r\n--B\r\nContent-Type: image/png\r\n\r\nlast part";
1661        let a = parse(data, Limits::default()).unwrap();
1662        assert_eq!(a.parts.len(), 2);
1663        assert_eq!(a.parts[0].body, b"first");
1664        assert_eq!(a.parts[1].body, b"last part");
1665    }
1666
1667    #[test]
1668    fn file_cut_inside_a_base64_part_gives_a_prefix() {
1669        let original: Vec<u8> = (0..100u8).collect();
1670        let encoded = base64::engine::general_purpose::STANDARD.encode(&original);
1671        for cut in 1..encoded.len() {
1672            let head = "Content-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\n\r\n";
1673            let data = format!("Content-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\n{head}{}", &encoded[..cut]);
1674            let a = parse(data.as_bytes(), Limits::default()).unwrap();
1675            let body = &a.parts[0].body;
1676            assert!(original.starts_with(body), "cut at {cut}");
1677        }
1678    }
1679
1680    #[test]
1681    fn random_bytes_never_panic() {
1682        let mut state = 0x1234_5678_9abc_def0u64;
1683        let dir = tempfile::tempdir().unwrap();
1684        for n in 0..400 {
1685            let len = (lcg(&mut state) as usize) * 2 % 300 + (n % 7);
1686            let mut buf: Vec<u8> = (0..len).map(|_| lcg(&mut state)).collect();
1687            if n % 2 == 1 {
1688                // Behind a valid multipart header, so the parts get read.
1689                let mut v = b"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\n".to_vec();
1690                v.append(&mut buf);
1691                buf = v;
1692            }
1693            let _ = parse(&buf, Limits::default());
1694            let _ = unpack(&buf, dir.path(), Limits::default(), Policy::Offline);
1695        }
1696    }
1697
1698    #[test]
1699    fn every_prefix_of_the_chrome_sample_never_panics() {
1700        let dir = tempfile::tempdir().unwrap();
1701        for n in 0..=CHROME.len() {
1702            let _ = parse(&CHROME[..n], Limits::default());
1703            if n % 7 == 0 {
1704                let _ = unpack(&CHROME[..n], dir.path(), Limits::default(), Policy::Offline);
1705            }
1706        }
1707    }
1708
1709    // ---- line ends and single parts ----
1710
1711    #[test]
1712    fn lf_line_ends_parse_like_crlf() {
1713        let mut lf = Vec::new();
1714        let mut i = 0;
1715        while i < CHROME.len() {
1716            if CHROME[i..].starts_with(b"\r\n") {
1717                lf.push(b'\n');
1718                i += 2;
1719            } else {
1720                lf.push(CHROME[i]);
1721                i += 1;
1722            }
1723        }
1724        assert!(!lf.contains(&b'\r'));
1725        let a = parse(CHROME, Limits::default()).unwrap();
1726        let b = parse(&lf, Limits::default()).unwrap();
1727        assert_eq!(a.parts.len(), b.parts.len());
1728        assert_eq!(a.root, b.root);
1729        assert_eq!((&a.subject, &a.address), (&b.subject, &b.address));
1730        for (x, y) in a.parts.iter().zip(&b.parts) {
1731            assert_eq!((&x.content_type, &x.location, &x.content_id), (&y.content_type, &y.location, &y.content_id));
1732            if x.content_type.starts_with("text/") {
1733                // Hard line breaks in a text part keep the file's line ends.
1734                let tx = decode_text(&x.body, x.charset.as_deref(), x.content_type == "text/html");
1735                let ty = decode_text(&y.body, y.charset.as_deref(), y.content_type == "text/html");
1736                assert_eq!(tx.replace("\r\n", "\n"), ty);
1737            } else {
1738                assert_eq!(x.body, y.body);
1739            }
1740        }
1741    }
1742
1743    #[test]
1744    fn single_part_html_unpacks_to_index() {
1745        let data = b"Content-Type: text/html; charset=utf-8\r\nContent-Location: https://example.com/\r\n\r\n<html><body>hi</body></html>";
1746        let (dir, u) = unpack_to(data, Policy::Offline);
1747        assert_eq!(u.files, [dir.path().join("index.html")]);
1748        assert!(read_text(dir.path(), "index.html").contains("<body>hi</body>"));
1749        assert_eq!(u.address.as_deref(), Some("https://example.com/"));
1750    }
1751}