1use std::collections::HashMap;
32use std::fs;
33use std::path::{Component, Path, PathBuf};
34
35use base64::Engine as _;
36
37pub const MAX_TOTAL: u64 = 256 * 1024 * 1024;
39pub const MAX_PARTS: usize = 20_000;
41const MAX_DEPTH: usize = 8;
43pub const ROOT_NAME: &str = "index.html";
45
46pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Policy {
55 Offline,
57 None,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Limits {
64 pub max_total: u64,
66 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Part {
100 pub content_type: String,
103 pub charset: Option<String>,
105 pub location: Option<String>,
107 pub content_id: Option<String>,
109 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#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Archive {
126 pub parts: Vec<Part>,
128 pub root: usize,
131 pub subject: Option<String>,
133 pub address: Option<String>,
136}
137
138#[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
148fn 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
178fn 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
202fn 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
259fn 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 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 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
304fn 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
337fn 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 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
375fn 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
394pub 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
426fn 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 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
452fn 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
468pub 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 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(¶ms, "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(¶ms, "charset").map(str::to_string), location, content_id, body: decoded });
530 Ok(())
531 }
532}
533
534pub 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(¶ms, "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#[derive(Debug, Clone, PartialEq, Eq)]
569pub struct Unpacked {
570 pub root: PathBuf,
572 pub files: Vec<PathBuf>,
574 pub subject: Option<String>,
576 pub address: Option<String>,
577}
578
579fn 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
608pub 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
627pub 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
641fn 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
652fn 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
680pub 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
691fn 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
700fn 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
727pub 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
770struct 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 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 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
833fn decode_entities(s: &str) -> String {
835 if !s.contains('&') {
836 return s.to_string();
837 }
838 s.replace(""", "\"")
839 .replace(""", "\"")
840 .replace("'", "'")
841 .replace("'", "'")
842 .replace("'", "'")
843 .replace("<", "<")
844 .replace(">", ">")
845 .replace("&", "&")
846 .replace("&", "&")
847 .replace("&", "&")
848}
849
850fn 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 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 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
907const URL_ATTRS: [&str; 8] = ["src", "href", "poster", "background", "data", "xlink:href", "action", "longdesc"];
909
910fn 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
918struct Attr {
921 name: String,
922 value: Option<(usize, usize)>,
923 quote: Option<char>,
924}
925
926fn 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
984fn 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
993fn escape_attr(v: &str, quote: Option<char>) -> String {
995 let v = v.replace('&', "&");
996 match quote {
997 Some('\'') => v.replace('\'', "'"),
998 _ => v.replace('"', """),
999 }
1000}
1001
1002fn 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
1022fn 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 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 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
1120pub 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 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
1157pub 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 const CHROME: &[u8] = include_bytes!("../testdata/mhtml/chrome.mhtml");
1194
1195 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 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 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 #[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 assert_eq!(decode_qp(b"ab=\r\ncd=\nef= \r\ngh=\t\nij"), b"abcdefghij");
1277 assert_eq!(decode_qp(b"a=zz b= c =g"), b"a=zz b= c =g");
1279 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 #[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 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 assert_eq!(decode_words("=?utf-8?Q?Caf?= =?utf-8?Q?=C3=A9?="), "Café");
1363 assert_eq!(decode_words("pre =?utf-8?Q?x?= post"), "pre x post");
1365 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 const SITE: &str = "https://example.com/dir/sub/page.html";
1385
1386 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&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("a.png")"></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), png_part("https://example.com/dir/sub/a.png"), png_part("https://example.com/dir/up.png"), png_part("https://example.com/abs.png"), png_part("https://cdn.example.com/x.png"), png_part("https://example.com/dir/sub/q.png?v=2"), html_part("https://example.com/dir/sub/frag.html", "<p>f</p>"), png_part("https://example.com/dir/sub/a&b.png"), png_part("https://example.com/dir/sub/s1.png"), png_part("https://example.com/dir/sub/s2.png"), part("Content-Type: text/css\nContent-Location: https://example.com/dir/sub/c.css", b"p{}"), part("Content-Type: text/css\nContent-Location: https://example.com/dir/css/main.css", b"div { background: url(../img/in.png) }"), png_part("https://example.com/dir/img/in.png"), png_part("https://example.com/other/b.png"), part("Content-Type: image/png\nContent-ID: <logo@x>", b"PNGDATA"), ]);
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\""), "& 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("1-a.png")\""), "{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 #[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 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 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 #[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 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 #[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 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}