From 53e1717523a8a4dd24d7bc3be93963aa2b009ae3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:25:27 -0500 Subject: [PATCH] craft-rtf: Outlook compressed RTF and body text, from MailCraft (#961) The outlook feature adds MailCraft's reader of PidTagRtfCompressed ([MS-OXRTFCP]) and of the text and encapsulated HTML ([MS-OXRTFEX]) of the RTF inside, from crates/pst/src/rtf.rs at a44a450. Font charsets use this crate's charset_code_page table; code pages are decoded by craft-mime. Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com> --- Cargo.lock | 2 + crates/rtf/Cargo.toml | 10 + crates/rtf/README.md | 11 + crates/rtf/src/lib.rs | 2 + crates/rtf/src/outlook.rs | 711 ++++++++++++++++++++++++++++++++ crates/rtf/src/outlook_tests.rs | 385 +++++++++++++++++ 6 files changed, 1121 insertions(+) create mode 100644 crates/rtf/src/outlook.rs create mode 100644 crates/rtf/src/outlook_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1be03246..0b52a450 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1556,7 +1556,9 @@ dependencies = [ name = "craft-rtf" version = "0.1.0" dependencies = [ + "craft-mime", "encoding_rs", + "proptest", ] [[package]] diff --git a/crates/rtf/Cargo.toml b/crates/rtf/Cargo.toml index aec1ca5c..a6724e25 100644 --- a/crates/rtf/Cargo.toml +++ b/crates/rtf/Cargo.toml @@ -10,6 +10,16 @@ repository.workspace = true [lints] workspace = true +[features] +# `outlook`: compressed RTF ([MS-OXRTFCP]) and the text or encapsulated HTML ([MS-OXRTFEX]) of a +# mail item's body. +outlook = ["dep:craft-mime"] + [dependencies] +# Character sets by Windows code page number, for the `outlook` module. +craft-mime = { workspace = true, optional = true } # The East Asian multibyte code pages (932, 936, 949, 950). encoding_rs = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/rtf/README.md b/crates/rtf/README.md index 9ae51fec..65c378c1 100644 --- a/crates/rtf/README.md +++ b/crates/rtf/README.md @@ -79,6 +79,17 @@ elsewhere are kept. `\sect` ends a paragraph. The reader does not recurse, and what it returns is never larger than a small multiple of the file: each character of text costs the file at least one byte. +## Outlook bodies (feature `outlook`) + +`outlook::decompress_rtf` undoes the compression of `PidTagRtfCompressed` ([MS-OXRTFCP]: `LZFu` +with its CRC checked, or `MELA` stored as is), never allocating past the caller's cap. +`outlook::rtf_text` gives the text a mail body shows (font charsets and `\ansicpg` decoded through +`craft_mime::charset`, `\uN` with its fallback skipped, destinations dropped), and +`outlook::rtf_html` the HTML of an encapsulated-HTML body ([MS-OXRTFEX], `\fromhtml1`). Groups nest +at most `MAX_RTF_DEPTH` (256) deep. From MailCraft `crates/pst/src/rtf.rs` at +[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5), +by Martin Ball and the MailCraft contributors. + ## Not here yet WordCraft's full reader and writer (styles, lists, fields, bookmarks, pictures, notes, diff --git a/crates/rtf/src/lib.rs b/crates/rtf/src/lib.rs index c6bba82e..3b22e484 100644 --- a/crates/rtf/src/lib.rs +++ b/crates/rtf/src/lib.rs @@ -33,6 +33,8 @@ pub mod codepage; mod date; mod lex; mod model; +#[cfg(feature = "outlook")] +pub mod outlook; mod read; mod text; mod write; diff --git a/crates/rtf/src/outlook.rs b/crates/rtf/src/outlook.rs new file mode 100644 index 00000000..7ce886b8 --- /dev/null +++ b/crates/rtf/src/outlook.rs @@ -0,0 +1,711 @@ +//! Rich text as Outlook keeps it in `PidTagRtfCompressed` (0x1009), in .pst and .msg files and +//! TNEF attachments: the compression of +//! [MS-OXRTFCP] ([`decompress_rtf`]) and the text of the RTF that comes out ([`rtf_text`]), +//! written from those two specifications and the public Rich Text Format specification. +//! +//! Both read hostile bytes: output is capped by the caller, a header's size is never trusted +//! for an allocation past that cap, nothing recurses (groups are counted, not nested calls), +//! and every loop ends with its input. + +use std::collections::HashMap; + +use craft_mime::charset::{code_page_label, decode}; + +use crate::codepage; + +/// Why rich text was not read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + /// The bytes are not what the format says; the text names what was wrong. + Corrupt(&'static str), + /// The compressed bytes do not have the CRC their header gives. + BadChecksum, + /// More would come out than the caller's cap allows. + TooLarge, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Corrupt(what) => f.write_str(what), + Error::BadChecksum => f.write_str("compressed rich text with a wrong checksum"), + Error::TooLarge => f.write_str("rich text larger than allowed"), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// The CRC of [MS-OXRTFCP] 2.1.3.1 and 3.2: the reflected CRC-32 polynomial, seeded with zero and +/// with no final inversion. +pub(crate) fn crc32(data: &[u8]) -> u32 { + let mut crc = 0u32; + for &b in data { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 != 0 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 }; + } + } + crc +} + +/// The dictionary a stream starts with ([MS-OXRTFCP] 2.1.2.1), 207 bytes. +const DICT_INIT: &[u8] = b"{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx"; +const _: () = assert!(DICT_INIT.len() == 207); +const DICT_SIZE: usize = 4096; +const HEADER: usize = 16; +const LZFU: u32 = 0x7546_5A4C; +const MELA: u32 = 0x414C_454D; + +fn le32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(<[u8; 4]>::try_from(s).ok()?)) +} + +/// The RTF inside `PidTagRtfCompressed` bytes: the 16-byte header, then either the text as it +/// is (`MELA`) or the dictionary-compressed runs (`LZFu`) whose CRC is checked. At most +/// `max_out` bytes come out; past that is [`Error::TooLarge`], never a bigger allocation. +pub fn decompress_rtf(data: &[u8], max_out: usize) -> Result> { + let (Some(comp_size), Some(raw_size), Some(kind), Some(crc)) = (le32(data, 0), le32(data, 4), le32(data, 8), le32(data, 12)) else { + return Err(Error::Corrupt("rich text shorter than its header")); + }; + let raw_size = usize::try_from(raw_size).unwrap_or(usize::MAX); + let body = data.get(HEADER..).unwrap_or_default(); + match kind { + MELA => { + // The reader may take the sizes the header gives; never more than is there. + let take = raw_size.min(body.len()); + if take > max_out { + return Err(Error::TooLarge); + } + Ok(body.get(..take).unwrap_or_default().to_vec()) + } + LZFU => { + if raw_size > max_out { + return Err(Error::TooLarge); + } + // COMPSIZE counts the 12 bytes of the header after itself. + let content_len = usize::try_from(comp_size) + .unwrap_or(usize::MAX) + .checked_sub(12) + .ok_or(Error::Corrupt("rich text with a size smaller than its header"))?; + let content = body.get(..content_len).ok_or(Error::Corrupt("rich text cut short"))?; + let out = lzfu(content, max_out, raw_size)?; + if crc32(content) != crc { + return Err(Error::BadChecksum); + } + Ok(out) + } + _ => Err(Error::Corrupt("rich text of a kind not known")), + } +} + +fn lzfu(content: &[u8], max_out: usize, raw_size: usize) -> Result> { + let mut dict = vec![0u8; DICT_SIZE]; + if let Some(head) = dict.get_mut(..DICT_INIT.len()) { + head.copy_from_slice(DICT_INIT); + } + let mut write = DICT_INIT.len(); + let mut out: Vec = Vec::with_capacity(raw_size.min(max_out).min(1 << 20)); + let mut at = 0usize; + let cut = Error::Corrupt("compressed rich text ends before its end mark"); + loop { + let control = *content.get(at).ok_or(cut)?; + at = at.saturating_add(1); + for bit in 0..8u32 { + if control >> bit & 1 == 0 { + let b = *content.get(at).ok_or(cut)?; + at = at.saturating_add(1); + if out.len() >= max_out { + return Err(Error::TooLarge); + } + out.push(b); + if let Some(d) = dict.get_mut(write) { + *d = b; + } + write = write.saturating_add(1) % DICT_SIZE; + } else { + let (Some(&hi), Some(&lo)) = (content.get(at), content.get(at.saturating_add(1))) else { return Err(cut) }; + at = at.saturating_add(2); + let word = usize::from(hi) << 8 | usize::from(lo); + let mut read = word >> 4; + if read == write { + return Ok(out); + } + for _ in 0..(word & 0xF).saturating_add(2) { + let b = dict.get(read).copied().unwrap_or(0); + if out.len() >= max_out { + return Err(Error::TooLarge); + } + out.push(b); + read = read.saturating_add(1) % DICT_SIZE; + if let Some(d) = dict.get_mut(write) { + *d = b; + } + write = write.saturating_add(1) % DICT_SIZE; + } + } + } + } +} + +/// Deepest nesting of groups read. +pub const MAX_RTF_DEPTH: usize = 256; + +/// Destinations whose content is not the document's text. +const SKIPPED: &[&str] = &[ + "fonttbl", + "colortbl", + "stylesheet", + "info", + "pict", + "object", + "header", + "headerl", + "headerr", + "headerf", + "footer", + "footerl", + "footerr", + "footerf", + "themedata", + "colorschememapping", + "latentstyles", + "datastore", + "listtable", + "listoverridetable", + "revtbl", + "rsidtbl", + "generator", + "filetbl", + "fldinst", + "xmlnstbl", + "mmathpr", + "pgdsctbl", + "template", + "private", + "bkmkstart", + "bkmkend", + "wgrffmtfilter", + "shpinst", + "nonshppict", + "pnseclvl", + "listtext", + "blipuid", + "falt", + "panose", + "fchars", + "lchars", + "xe", + "tc", + "tcn", + "ud", + "upr", + "title", + "subject", + "author", + "operator", + "company", + "keywords", + "doccomm", + "comment", + "category", + "manager", + "hlinkbase", + "buptim", + "creatim", + "revtim", + "printim", + "txe", + "rxe", + "pnseclvl", + "protusertbl", + "defchp", + "defpap", + "fontemb", + "fontfile", + "background", + "docvar", + "userprops", + "mhtmltag", + "htmltag", + "factoidname", + "oleclsid", + "objdata", +]; + +#[derive(Clone, Copy)] +struct Group { + /// Skip characters after `\u` (`\ucN`). + uc: u32, + skip: bool, + /// In the font table: `\fN` names the font that `\fcharsetN` is for. + fonttbl: bool, + /// The font being defined (in the font table). + defining: i64, + /// The font text is in (`\fN`), if one was chosen. + font: Option, + /// Encapsulated HTML: under `\htmlrtf`, what isn't part of the HTML. + suppressed: bool, + /// Encapsulated HTML: inside an `\htmltag` group. + in_tag: bool, +} + +impl Group { + fn hidden(&self) -> bool { + self.skip || self.suppressed + } +} + +/// Most fonts whose character set is kept. +const MAX_FONTS: usize = 1024; +/// Most bytes of `\'hh` kept waiting for a character boundary. +const PENDING: usize = 4096; + +struct Out { + text: String, + max: usize, + /// Text was dropped for the cap. + cut: bool, + pending: Vec, + /// The code page of `pending`. + pending_cp: u32, + high: Option, + ansicpg: Option, + mac: bool, + deff: Option, + /// `\fcharset` of each font in the font table. + fonts: HashMap, + /// Reading encapsulated HTML rather than the text a document shows. + html: bool, +} + +/// The code page a font character set stands for ([MSFT-RTF] `\fcharsetN`): `None` for the +/// document's own (0 is Windows ANSI, 1252, which is not the document's when it says +/// `\ansicpg` otherwise: [MS-OXRTFEX] 3.1 marks one font `\fcharset0` in a 1251 document to +/// have 0xA8 read as U+00A8). Symbol (2), Johab (130) and the OEM sets (254, 255) are read in the document's. +fn charset_code_page(charset: i64) -> Option { + match charset { + 0 => Some(1252), + 2 | 254 | 255 => None, + other => i32::try_from(other).ok().and_then(codepage::charset_code_page).map(u32::from), + } +} + +/// Whether `b` begins a two-byte character in code page `cp`. +fn is_lead(cp: u32, b: u8) -> bool { + match cp { + 932 => matches!(b, 0x81..=0x9F | 0xE0..=0xFC), + 936 | 949 | 950 => matches!(b, 0x81..=0xFE), + _ => false, + } +} + +impl Out { + fn new(max: usize) -> Out { + Out { + text: String::new(), + max, + cut: false, + pending: Vec::new(), + pending_cp: 1252, + high: None, + ansicpg: None, + mac: false, + deff: None, + fonts: HashMap::new(), + html: false, + } + } + + /// The code page of the document: `\ansicpg`, else the Macintosh's for `\mac`, else 1252. + fn document_cp(&self) -> u32 { + self.ansicpg.unwrap_or(if self.mac { 10000 } else { 1252 }) + } + + /// The code page of bytes in font `font` (else the default font). + fn code_page(&self, font: Option) -> u32 { + font.or(self.deff).and_then(|f| self.fonts.get(&f)).and_then(|cs| charset_code_page(*cs)).unwrap_or_else(|| self.document_cp()) + } + + fn full(&self) -> bool { + self.text.len() >= self.max + } + + fn flush(&mut self) { + let bytes = std::mem::take(&mut self.pending); + self.decode(&bytes); + } + + fn decode(&mut self, bytes: &[u8]) { + if !bytes.is_empty() { + let decoded = decode(bytes, code_page_label(i64::from(self.pending_cp)).unwrap_or("windows-1252")); + self.push_str(&decoded); + } + } + + /// Decodes the whole characters waiting, and keeps a lead byte whose second is not here yet. + fn flush_whole(&mut self) { + let mut cut = 0usize; + let mut i = 0usize; + while i < self.pending.len() { + let step = if self.pending.get(i).is_some_and(|b| is_lead(self.pending_cp, *b)) { 2 } else { 1 }; + if i.saturating_add(step) > self.pending.len() { + break; + } + i = i.saturating_add(step); + cut = i; + } + let rest = self.pending.split_off(cut); + self.flush(); + self.pending = rest; + } + + fn push_str(&mut self, s: &str) { + for c in s.chars() { + // Controls other than the line end and the tab are not text. + if c.is_control() && c != '\n' && c != '\t' { + continue; + } + if self.full() { + self.cut = true; + return; + } + self.text.push(c); + } + } + + fn char(&mut self, c: char) { + self.flush_high(); + self.flush(); + self.push_str(c.encode_utf8(&mut [0; 4])); + } + + fn flush_high(&mut self) { + if self.high.take().is_some() { + self.push_str("\u{FFFD}"); + } + } + + /// A byte of 8-bit text in code page `cp`. + fn byte(&mut self, b: u8, cp: u32) { + self.flush_high(); + if cp != self.pending_cp { + self.flush(); + self.pending_cp = cp; + } + if self.pending.len() >= PENDING { + self.flush_whole(); + } + // (A buffer that is full of one character's bytes is not a text any code page here + // has; it is let grow by one rather than dropped.) + self.pending.push(b); + } + + fn unit(&mut self, u: u16) { + self.flush(); + match (self.high.take(), u) { + (Some(h), 0xDC00..=0xDFFF) => { + let c = 0x10000u32.saturating_add((u32::from(h).saturating_sub(0xD800)) << 10).saturating_add(u32::from(u).saturating_sub(0xDC00)); + self.push_str(&char::from_u32(c).unwrap_or('\u{FFFD}').to_string()); + } + (Some(_), _) => { + self.push_str("\u{FFFD}"); + self.unit(u); + } + (None, 0xD800..=0xDBFF) => self.high = Some(u), + (None, 0xDC00..=0xDFFF) => self.push_str("\u{FFFD}"), + (None, u) => self.push_str(&char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}').to_string()), + } + } +} + +/// The text of an RTF document: paragraphs and lines as newlines, tabs, `\'hh` bytes in the +/// code page of the font they are set in (its `\fcharset`, else the document's `\ansicpg`, +/// Windows-1252 when it names none), `\uN` with its `\ucN` fallback skipped, and none of the +/// font table, colours, styles, pictures, objects, headers or footers, nor any `{\*…}` +/// destination (for encapsulated HTML, [MS-OXRTFEX], that leaves the text the document shows +/// and not the `\htmltag` HTML, and the text as it is for `\fromtext`). Control characters +/// other than the line end and the tab are dropped. At most `max` bytes of text; whether more +/// was cut is the second part of the answer. Groups nest at most [`MAX_RTF_DEPTH`] deep, else +/// an error; unbalanced braces are read as they come. +pub fn rtf_text(rtf: &[u8], max: usize) -> Result<(String, bool)> { + read(rtf, max, false).map(|(text, cut)| (tidy(&text), cut)) +} + +/// The HTML a document keeps when it is encapsulated HTML ([MS-OXRTFEX] 2.2.3): what its +/// `\htmltag` groups hold and the text no `\htmlrtf` suppresses, under the same caps as +/// [`rtf_text`]. `None` for any other document. The HTML is as hostile as the file: whoever +/// shows it reads it as any other message's. +pub fn rtf_html(rtf: &[u8], max: usize) -> Result> { + if !encapsulates_html(rtf) { + return Ok(None); + } + read(rtf, max, true).map(Some) +} + +/// Whether one of the document's first ten tokens says it was HTML (2.2.3.1). +fn encapsulates_html(rtf: &[u8]) -> bool { + let mut i = rtf.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rtf.len()); + if rtf.get(i..i.saturating_add(5)) != Some(b"{\\rtf") { + return false; + } + for _ in 0..10 { + match rtf.get(i) { + Some(b'{') => i = i.saturating_add(1), + Some(b'\\') => { + let from = i.saturating_add(1); + i = from; + while rtf.get(i).is_some_and(u8::is_ascii_alphabetic) { + i = i.saturating_add(1); + } + let word = rtf.get(from..i).unwrap_or_default(); + if word.is_empty() { + return false; + } + let digits = i; + while rtf.get(i).is_some_and(u8::is_ascii_digit) { + i = i.saturating_add(1); + } + match word { + b"fromhtml" => return rtf.get(digits..i) != Some(b"0"), + b"fromtext" => return false, + _ => {} + } + if rtf.get(i) == Some(&b' ') { + i = i.saturating_add(1); + } + } + _ => return false, + } + } + false +} + +fn read(rtf: &[u8], max: usize, html: bool) -> Result<(String, bool)> { + let start = rtf.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rtf.len()); + if rtf.get(start..start.saturating_add(5)) != Some(b"{\\rtf") { + return Err(Error::Corrupt("not rich text")); + } + let mut out = Out::new(max); + out.html = html; + let mut stack: Vec = Vec::new(); + let mut cur = Group { uc: 1, skip: false, fonttbl: false, defining: 0, font: None, suppressed: false, in_tag: false }; + let mut star = false; + let mut first = false; + let mut skip_chars = 0u32; + let mut i = start; + while let Some(&b) = rtf.get(i) { + i = i.saturating_add(1); + match b { + b'{' => { + if stack.len() >= MAX_RTF_DEPTH { + return Err(Error::Corrupt("rich text nested too deeply")); + } + stack.push(cur); + first = true; + skip_chars = 0; + } + b'}' => { + out.flush(); + out.flush_high(); + if let Some(g) = stack.pop() { + cur = g; + } + first = false; + skip_chars = 0; + } + b'\r' | b'\n' => {} + b'\\' => { + let Some(&n) = rtf.get(i) else { break }; + i = i.saturating_add(1); + match n { + b'\\' | b'{' | b'}' => { + first = false; + if !cur.hidden() && !take(&mut skip_chars) { + out.char(char::from(n)); + } + } + // A backslash before a line end is a paragraph end. + b'\r' | b'\n' => { + first = false; + if !cur.hidden() { + out.char('\n'); + } + } + b'\'' => { + first = false; + let hex = + rtf.get(i..i.saturating_add(2)).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + if let Some(v) = hex { + i = i.saturating_add(2); + if !cur.hidden() && !take(&mut skip_chars) { + let cp = out.code_page(cur.font); + out.byte(v, cp); + } + } + } + b'*' => { + cur.skip = true; + first = false; + star = true; + } + b'~' => symbol(&mut out, &cur, '\u{A0}', &mut first), + b'_' => { + let hyphen = if out.html { '\u{AD}' } else { '\u{2011}' }; + symbol(&mut out, &cur, hyphen, &mut first); + } + b'-' | b'|' | b':' => first = false, + n if n.is_ascii_alphabetic() => { + let from = i.saturating_sub(1); + while rtf.get(i).is_some_and(u8::is_ascii_alphabetic) { + i = i.saturating_add(1); + } + let word = rtf.get(from..i).and_then(|w| std::str::from_utf8(w).ok()).unwrap_or_default(); + let word = if word.len() > 32 { word.get(..32).unwrap_or(word) } else { word }; + // An optional parameter, then at most one space of delimiter. + let mut neg = false; + if rtf.get(i) == Some(&b'-') { + neg = true; + i = i.saturating_add(1); + } + let mut num: Option = None; + while let Some(d) = rtf.get(i).filter(|d| d.is_ascii_digit()) { + num = Some(num.unwrap_or(0).saturating_mul(10).saturating_add(i64::from(d.saturating_sub(b'0'))).min(1 << 40)); + i = i.saturating_add(1); + } + let num = num.map(|v| if neg { v.saturating_neg() } else { v }); + if rtf.get(i) == Some(&b' ') { + i = i.saturating_add(1); + } + let was_first = std::mem::replace(&mut first, false); + // The HTML of a tag is read whatever suppresses what is round it, by the + // document's own code page (2.2.3.2). + if html && std::mem::take(&mut star) && word == "htmltag" && !stack.last().is_some_and(|g| g.skip) { + cur.skip = false; + cur.in_tag = true; + cur.suppressed = false; + cur.font = None; + } + if was_first && SKIPPED.contains(&word.to_ascii_lowercase().as_str()) { + cur.skip = true; + cur.fonttbl = word.eq_ignore_ascii_case("fonttbl"); + } + skip_chars = 0; + control(word, num, &mut cur, &mut out, &mut skip_chars, &mut i, rtf); + } + _ => first = false, + } + } + b'\t' => { + first = false; + if !cur.hidden() && !take(&mut skip_chars) { + out.char('\t'); + } + } + 0x00..=0x1F => {} + _ => { + first = false; + if cur.hidden() || take(&mut skip_chars) { + continue; + } + if b.is_ascii() { + out.char(char::from(b)); + } else { + let cp = out.code_page(cur.font); + out.byte(b, cp); + } + } + } + } + out.flush(); + out.flush_high(); + let cut = out.cut; + Ok((out.text, cut)) +} + +fn take(skip: &mut u32) -> bool { + if *skip > 0 { + *skip = skip.saturating_sub(1); + true + } else { + false + } +} + +fn symbol(out: &mut Out, cur: &Group, c: char, first: &mut bool) { + *first = false; + if !cur.hidden() { + out.char(c); + } +} + +fn control(word: &str, num: Option, cur: &mut Group, out: &mut Out, skip_chars: &mut u32, i: &mut usize, rtf: &[u8]) { + match word { + "ansicpg" => out.ansicpg = num.and_then(|n| u32::try_from(n).ok()), + "mac" => out.mac = true, + // The fonts: which character set each has, and which one the text is in. + "f" if cur.fonttbl => cur.defining = num.unwrap_or(0), + "fcharset" if cur.fonttbl => { + if let Some(cs) = num + && (out.fonts.len() < MAX_FONTS || out.fonts.contains_key(&cur.defining)) + { + out.fonts.insert(cur.defining, cs); + } + } + "f" if !cur.skip => cur.font = num, + "deff" => out.deff = num, + "plain" if !cur.skip => cur.font = None, + "uc" => cur.uc = num.and_then(|n| u32::try_from(n).ok()).unwrap_or(1).min(16), + "htmlrtf" if out.html && !cur.in_tag => cur.suppressed = num != Some(0), + "u" if !cur.hidden() => { + if let Some(n) = num { + // A negative number is the unit minus 65536; the fallback after it is skipped. + let unit = if n < 0 { n.saturating_add(65536) } else { n }; + out.unit(u16::try_from(unit & 0xFFFF).unwrap_or(0xFFFD)); + *skip_chars = cur.uc; + } + } + "bin" => { + let n = num.and_then(|n| usize::try_from(n).ok()).unwrap_or(0); + *i = i.saturating_add(n).min(rtf.len()); + } + "par" | "line" if !cur.hidden() => out.char('\n'), + "sect" | "page" | "row" if !cur.hidden() && !out.html => out.char('\n'), + "tab" if !cur.hidden() => out.char('\t'), + "cell" if !cur.hidden() && !out.html => out.char('\t'), + "emdash" if !cur.hidden() => out.char('\u{2014}'), + "endash" if !cur.hidden() => out.char('\u{2013}'), + "lquote" if !cur.hidden() => out.char('\u{2018}'), + "rquote" if !cur.hidden() => out.char('\u{2019}'), + "ldblquote" if !cur.hidden() => out.char('\u{201C}'), + "rdblquote" if !cur.hidden() => out.char('\u{201D}'), + "bullet" if !cur.hidden() => out.char('\u{2022}'), + "emspace" | "enspace" | "qmspace" if !cur.hidden() => out.char(' '), + _ => {} + } +} + +/// At most one blank line in a row, and no blanks at the ends. +fn tidy(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut newlines = 0u32; + for c in s.chars() { + if c == '\n' { + newlines = newlines.saturating_add(1); + if newlines > 2 { + continue; + } + } else { + newlines = 0; + } + out.push(c); + } + out.trim().to_string() +} + +#[cfg(test)] +#[path = "outlook_tests.rs"] +mod tests; diff --git a/crates/rtf/src/outlook_tests.rs b/crates/rtf/src/outlook_tests.rs new file mode 100644 index 00000000..7eb41dbd --- /dev/null +++ b/crates/rtf/src/outlook_tests.rs @@ -0,0 +1,385 @@ +//! Compressed rich text ([MS-OXRTFCP] section 3 samples) and the text of RTF. + +use proptest::prelude::*; + +use super::{Error, MAX_RTF_DEPTH, crc32, decompress_rtf, rtf_html, rtf_text}; + +/// [MS-OXRTFCP] 3.1.1: a simple compressed RTF. +const EXAMPLE_1: [u8; 49] = [ + 0x2d, 0x00, 0x00, 0x00, 0x2b, 0x00, 0x00, 0x00, 0x4c, 0x5a, 0x46, 0x75, 0xf1, 0xc5, 0xc7, 0xa7, 0x03, 0x00, 0x0a, 0x00, 0x72, 0x63, 0x70, 0x67, + 0x31, 0x32, 0x35, 0x42, 0x32, 0x0a, 0xf3, 0x20, 0x68, 0x65, 0x6c, 0x09, 0x00, 0x20, 0x62, 0x77, 0x05, 0xb0, 0x6c, 0x64, 0x7d, 0x0a, 0x80, 0x0f, + 0xa0, +]; + +/// [MS-OXRTFCP] 3.1.2: a reference that reads past the write position. +const EXAMPLE_2: [u8; 30] = [ + 0x1a, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x4c, 0x5a, 0x46, 0x75, 0xe2, 0xd4, 0x4b, 0x51, 0x41, 0x00, 0x04, 0x20, 0x57, 0x58, 0x59, 0x5a, + 0x0d, 0x6e, 0x7d, 0x01, 0x0e, 0xb0, +]; + +fn mela(rtf: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&u32::try_from(rtf.len() + 12).unwrap().to_le_bytes()); + v.extend_from_slice(&u32::try_from(rtf.len()).unwrap().to_le_bytes()); + v.extend_from_slice(b"MELA"); + v.extend_from_slice(&[0; 4]); + v.extend_from_slice(rtf); + v +} + +#[test] +fn spec_example_1_decompresses() { + let out = decompress_rtf(&EXAMPLE_1, 1 << 20).unwrap(); + assert_eq!(out, b"{\\rtf1\\ansi\\ansicpg1252\\pard hello world}\r\n"); + assert_eq!(crc32(&EXAMPLE_1[16..]), 0xa7c7_c5f1); + assert_eq!(rtf_text(&out, 1000).unwrap(), ("hello world".to_string(), false)); +} + +#[test] +fn spec_example_2_reads_past_the_write_position() { + let out = decompress_rtf(&EXAMPLE_2, 1 << 20).unwrap(); + assert_eq!(out, b"{\\rtf1 WXYZWXYZWXYZWXYZWXYZ}"); + assert_eq!(out.len(), 0x1c); +} + +#[test] +fn uncompressed_is_taken_as_it_is() { + let rtf = b"{\\rtf1 plain}"; + assert_eq!(decompress_rtf(&mela(rtf), 100).unwrap(), rtf); + // A header that lies about its size is held to what is there. + let mut lying = mela(rtf); + lying.get_mut(4..8).unwrap().copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(decompress_rtf(&lying, 100).unwrap(), rtf); + assert!(matches!(decompress_rtf(&mela(rtf), 3), Err(Error::TooLarge))); +} + +#[test] +fn a_bad_crc_is_an_error() { + let mut bad = EXAMPLE_1; + bad[12] ^= 1; + assert!(matches!(decompress_rtf(&bad, 1 << 20), Err(Error::BadChecksum))); + let mut bad = EXAMPLE_1; + bad[20] ^= 0x40; + assert!(decompress_rtf(&bad, 1 << 20).is_err()); +} + +#[test] +fn truncation_is_an_error_at_every_length() { + for n in 0..EXAMPLE_1.len() { + assert!(decompress_rtf(&EXAMPLE_1[..n], 1 << 20).is_err(), "{n}"); + } + // A header whose size is right but whose runs have no end mark. + let mut v = EXAMPLE_1[..40].to_vec(); + v[..4].copy_from_slice(&28u32.to_le_bytes()); + assert!(decompress_rtf(&v, 1 << 20).is_err()); +} + +#[test] +fn a_header_that_lies_about_the_size_costs_nothing() { + let mut huge = EXAMPLE_1; + huge[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(decompress_rtf(&huge, 1 << 20), Err(Error::TooLarge))); + // Under the cap it is the stream that decides. + let mut small = EXAMPLE_1; + small[4..8].copy_from_slice(&1000u32.to_le_bytes()); + assert_eq!(decompress_rtf(&small, 1 << 20).unwrap().len(), 0x2b); +} + +#[test] +fn output_is_capped_while_it_is_made() { + assert!(matches!(decompress_rtf(&EXAMPLE_1, 10), Err(Error::TooLarge))); + // A long stream (no end mark) is stopped at the cap, not at its end. + let mut body = Vec::new(); + for _ in 0..200_000 { + body.push(0x00); + body.extend_from_slice(b"abcdefgh"); + } + let mut v = Vec::new(); + v.extend_from_slice(&u32::try_from(body.len() + 12).unwrap().to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(b"LZFu"); + v.extend_from_slice(&crc32(&body).to_le_bytes()); + v.extend_from_slice(&body); + assert!(matches!(decompress_rtf(&v, 1 << 16), Err(Error::TooLarge))); +} + +#[test] +fn an_unknown_kind_is_an_error() { + let mut v = EXAMPLE_1; + v[8..12].copy_from_slice(b"XXXX"); + assert!(matches!(decompress_rtf(&v, 100), Err(Error::Corrupt(_)))); +} + +fn text(rtf: &str) -> String { + rtf_text(rtf.as_bytes(), 1 << 20).unwrap().0 +} + +#[test] +fn paragraphs_and_tabs() { + assert_eq!(text("{\\rtf1\\ansi\\deff0 One\\par Two\\line Three\\tab x\\par}"), "One\nTwo\nThree\tx"); + assert_eq!(text("{\\rtf1 a\\par\\par\\par\\par\\par b}"), "a\n\nb"); + assert_eq!(text("{\\rtf1 braces \\{ \\} and \\\\ end}"), "braces { } and \\ end"); + assert_eq!(text("{\\rtf1 line\r\nbreaks in the file are not breaks}"), "linebreaks in the file are not breaks"); +} + +#[test] +fn hex_escapes_use_the_code_page() { + assert_eq!(text("{\\rtf1\\ansi\\ansicpg1252 caf\\'e9 \\'80}"), "caf\u{e9} \u{20ac}"); + assert_eq!(text("{\\rtf1\\ansi\\ansicpg1251 \\'cf\\'f0\\'e8\\'e2\\'e5\\'f2}"), "\u{41f}\u{440}\u{438}\u{432}\u{435}\u{442}"); + // Two bytes of one character (Shift JIS). + assert_eq!(text("{\\rtf1\\ansi\\ansicpg932 \\'82\\'a0}"), "\u{3042}"); + // No code page: Windows-1252. + assert_eq!(text("{\\rtf1 \\'e9}"), "\u{e9}"); +} + +#[test] +fn unicode_escapes_skip_their_fallback() { + assert_eq!(text("{\\rtf1\\uc1\\u8364? x}"), "\u{20ac} x"); + assert_eq!(text("{\\rtf1\\uc2\\u8364\\'80\\'80 x}"), "\u{20ac} x"); + assert_eq!(text("{\\rtf1\\uc0\\u8364 x}"), "\u{20ac}x"); + assert_eq!(text("{\\rtf1\\u-3913 ?}"), "\u{f0b7}"); + assert_eq!(text("{\\rtf1\\u-10179?\\u-8704? x}"), "\u{1f600} x"); + // A lone surrogate is a replacement character, not a panic. + assert_eq!(text("{\\rtf1\\u-10179? x}"), "\u{fffd} x"); + // \ucN belongs to its group. + assert_eq!(text("{\\rtf1{\\uc0}\\u65?B}"), "AB"); +} + +#[test] +fn nested_groups_and_skipped_destinations() { + let rtf = "{\\rtf1\\ansi{\\fonttbl{\\f0 Arial;}}{\\colortbl;\\red0\\green0\\blue0;}{\\stylesheet{\\s1 Heading;}}{\\*\\generator Hidden;}{\\info{\\title T}}\ + {\\pict\\wmetafile8 0102030405}{\\header Head}\\pard{\\b bold {\\i both}} plain{\\*\\htmltag64 }}"; + assert_eq!(text(rtf), "bold both plain"); +} + +#[test] +fn encapsulated_text_and_html_give_what_the_document_shows() { + assert_eq!(text("{\\rtf1\\ansi\\fromtext\\deff0{\\fonttbl{\\f0\\fswiss Arial;}}\\pard Hello\\par}"), "Hello"); + let html = "{\\rtf1\\ansi\\fromhtml1 {\\*\\htmltag19 }\\htmlrtf {\\htmlrtf0 Hello {\\*\\htmltag84 }there\\htmlrtf \\par\\htmlrtf0 }}"; + assert_eq!(text(html), "Hello there"); +} + +#[test] +fn bin_data_is_skipped_by_its_count() { + let mut rtf = b"{\\rtf1 a{\\pict\\bin5 }}{b".to_vec(); + let at = rtf.iter().position(|&b| b == b'5').unwrap() + 2; + rtf.splice(at..at, *b"}{\\par"); + assert_eq!(rtf_text(&rtf, 100).unwrap().0, "ab"); + // A count past the end is the end. + assert_eq!(rtf_text(b"{\\rtf1 a\\bin99999999999 bc", 100).unwrap().0, "a"); +} + +#[test] +fn bad_input_is_an_error_or_what_could_be_read() { + assert!(rtf_text(b"plain text", 100).is_err()); + assert!(rtf_text(b"", 100).is_err()); + assert_eq!(text("{\\rtf1 open {group {never closed"), "open group never closed"); + assert_eq!(text("{\\rtf1 closed}} too} many} text"), "closed too many text"); + assert_eq!(text("{\\rtf1 ends in a slash\\"), "ends in a slash"); + assert_eq!(text("{\\rtf1 \\'zz bad hex}"), "zz bad hex"); +} + +#[test] +fn depth_is_capped() { + let deep = format!("{{\\rtf1 {}x{}", "{".repeat(MAX_RTF_DEPTH + 10), "}".repeat(MAX_RTF_DEPTH + 10)); + assert!(matches!(rtf_text(deep.as_bytes(), 100), Err(Error::Corrupt(_)))); + let ok = format!("{{\\rtf1 {}x{}", "{".repeat(100), "}".repeat(101)); + assert_eq!(text(&ok), "x"); +} + +#[test] +fn text_is_capped() { + let long = format!("{{\\rtf1 {}}}", "word ".repeat(100_000)); + let (t, cut) = rtf_text(long.as_bytes(), 1000).unwrap(); + assert!(t.len() <= 1000 && cut); + assert!(!rtf_text(b"{\\rtf1 short}", 1000).unwrap().1); + // Folded blank lines still say the text was cut. + let folded = format!("{{\\rtf1 {}}}", "x\\par\\par\\par\\par ".repeat(1000)); + let (t, cut) = rtf_text(folded.as_bytes(), 1000).unwrap(); + assert!(cut && t.len() < 1000, "{}", t.len()); + let multibyte = format!("{{\\rtf1 {}}}", "\\u233?".repeat(10_000)); + let t = rtf_text(multibyte.as_bytes(), 101).unwrap().0; + assert!(t.len() <= 102 && t.chars().all(|c| c == '\u{e9}')); +} + +#[test] +fn each_font_has_its_code_page() { + // RichEdit's shape: ANSI document, a font of its own character set. + assert_eq!( + text("{\\rtf1\\ansi\\ansicpg1252{\\fonttbl{\\f0\\fnil\\fcharset128 Yu Gothic;}}\\f0\\'93\\'fa\\'96\\'7b\\'8c\\'ea}"), + "\u{65e5}\u{672c}\u{8a9e}" + ); + assert_eq!(text("{\\rtf1\\ansi\\ansicpg1252{\\fonttbl{\\f0\\fnil\\fcharset204 Calibri;}}\\f0\\'cf\\'f0\\'e8 x}"), "\u{41f}\u{440}\u{438} x"); + // The font goes with its group. + assert_eq!( + text("{\\rtf1\\ansi\\ansicpg1252{\\fonttbl{\\f0\\fcharset204 A;}{\\f1\\fcharset161 B;}}{\\f0 \\'cf}\\'cf{\\f1\\'e1}\\f0\\'cf}"), + "\u{41f}\u{cf}\u{3b1}\u{41f}" + ); + // A change of font in the middle of a run of bytes. + assert_eq!(text("{\\rtf1\\ansi{\\fonttbl{\\f0\\fcharset204 A;}{\\f1\\fcharset0 B;}}\\f0\\'cf\\f1\\'cf\\f0\\'cf}"), "\u{41f}\u{cf}\u{41f}"); + // A flat font table, \deff and no charset at all. + assert_eq!(text("{\\rtf1\\ansi\\deff1{\\fonttbl\\f0\\fcharset0 A;\\f1\\fcharset204 B;}\\'cf}"), "\u{41f}"); + assert_eq!(text("{\\rtf1\\ansi\\ansicpg1251{\\fonttbl{\\f0 A;}}\\f0\\'a8\\f9\\'a8}"), "\u{401}\u{401}"); +} + +#[test] +fn the_sample_of_ms_oxrtfex_reads_its_font_charset() { + // [MS-OXRTFEX] 3.1: a 1251 document, and one font marked \fcharset0 for ¨ (0xA8, U+00A8). + let rtf = "{\\rtf1\\ANSI\\ansicpg1251\\fromhtml1 \\deff0\r\n{\\fonttbl {\\f0\\fmodern Courier New;}{\\f1\\fswiss Arial;}{\\f2\\fswiss\\fcharset0 Arial;}}\ + \\uc1\\pard\\plain\\deftab360 \\f0\\fs24 {\\htmlrtf \\f1 \\htmlrtf0 \\'cf {\\*\\htmltag ¨}\\htmlrtf {\\f2\\'a8}\\htmlrtf0 \\'a8}}"; + assert_eq!(text(rtf), "\u{41f} \u{a8}\u{401}"); +} + +#[test] +fn bytes_past_the_buffer_are_not_dropped() { + let dbcs = format!("{{\\rtf1\\ansi\\ansicpg932 {}}}", "\\'82\\'a0".repeat(3000)); + assert_eq!(text(&dbcs), "\u{3042}".repeat(3000)); + // An odd byte first, so the 4096-byte mark falls inside a pair. + let shifted = format!("{{\\rtf1\\ansi\\ansicpg932 \\'41{}}}", "\\'82\\'a0".repeat(3000)); + assert_eq!(text(&shifted), format!("A{}", "\u{3042}".repeat(3000))); + let single = format!("{{\\rtf1\\ansi\\ansicpg1252 {}}}", "\\'e9".repeat(5000)); + assert_eq!(text(&single), "\u{e9}".repeat(5000)); + let raw: Vec = [b"{\\rtf1\\ansi\\ansicpg1252 ".as_slice(), &[0xE9u8; 5000], b"}"].concat(); + assert_eq!(rtf_text(&raw, 1 << 20).unwrap().0, "\u{e9}".repeat(5000)); +} + +#[test] +fn ansicpg_wins_over_mac_in_either_order() { + assert_eq!(text("{\\rtf1\\ansicpg1251\\mac \\'cf}"), "\u{41f}"); + assert_eq!(text("{\\rtf1\\mac\\ansicpg1251 \\'cf}"), "\u{41f}"); + assert_eq!(text("{\\rtf1\\mac \\'a5}"), "\u{2022}"); +} + +#[test] +fn control_characters_are_not_text() { + assert_eq!(text("{\\rtf1 a\\'00b\\u0? c\\'07\\u1\\u9? d\\u10?e}"), "ab c\t d\ne"); +} + +#[test] +fn a_literal_tab_and_a_backslash_line_end() { + assert_eq!(text("{\\rtf1 a\tb\\\r\nc\\\nd\\\re}"), "a\tb\nc\nd\ne"); +} + +#[test] +fn the_code_pages_of_the_mail_are_the_code_pages_of_the_text() { + // Pages the first version of the text reader lacked: KOI8-R, ISO 8859-2, GB 18030's, EUC-KR's other number. + assert_eq!(text("{\\rtf1\\ansi\\ansicpg20866 \\'d0\\'d2}"), "\u{43f}\u{440}"); + assert_eq!(text("{\\rtf1\\ansi\\ansicpg28592 \\'b1}"), "\u{105}"); + assert_eq!(text("{\\rtf1\\ansi\\ansicpg51949 \\'b0\\'a1}"), "\u{ac00}"); + assert_eq!(craft_mime::charset::code_page_label(20866), Some("koi8-r")); +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })] + + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..4096)) { + let _ = decompress_rtf(&bytes, 1 << 16); + let _ = rtf_text(&bytes, 1 << 12); + let mut with_start = b"{\\rtf1".to_vec(); + with_start.extend_from_slice(&bytes); + let _ = rtf_text(&with_start, 1 << 12); + } + + #[test] + fn changed_streams_never_panic(at in 0usize..49, to in any::(), cut in 0usize..49) { + let mut v = EXAMPLE_1.to_vec(); + if let Some(b) = v.get_mut(at) { *b = to; } + v.truncate(cut.max(1)); + if let Ok(rtf) = decompress_rtf(&v, 1 << 12) { let _ = rtf_text(&rtf, 100); } + } + + #[test] + fn rtf_with_valid_start_is_bounded(body in "[\\\\{}a-z0-9 '*-]{0,600}", max in 0usize..300) { + let rtf = format!("{{\\rtf1 {body}"); + if let Ok((t, _)) = rtf_text(rtf.as_bytes(), max) { prop_assert!(t.len() <= max.saturating_add(4)); } + } +} + +/// The document of [MS-OXRTFEX] 2.1.3.1.3's kind: tags in groups, what isn't HTML suppressed. +const ENCAPSULATED: &[u8] = + b"{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 \\deff0{\\fonttbl{\\f0\\fswiss Arial;}{\\f1\\fcharset204 Cyr;}}{\\colortbl;\\red0;}\ +{\\*\\htmltag64

}{\\htmlrtf \\f1 \\htmlrtf0 caf\\'e9 {\\*\\htmltag }{\\htmlrtf \\b \\htmlrtf0 bold{\\*\\htmltag }}\ +\\htmlrtf \\par \\htmlrtf0 {\\*\\htmltag &}\\htmlrtf &\\htmlrtf0 \\u8364?{\\*\\mhtmltag }{\\*\\other no}{\\*\\htmltag

}}}"; + +#[test] +fn encapsulated_html_comes_out_as_it_was() { + let (html, cut) = rtf_html(ENCAPSULATED, 1 << 16).unwrap().unwrap(); + assert!(!cut); + assert_eq!(html, "

caf\u{439} bold&\u{20AC}

"); +} + +#[test] +fn a_tag_is_kept_even_where_the_text_round_it_is_suppressed() { + let rtf = b"{\\rtf1\\fromhtml1 \\htmlrtf {\\*\\htmltag }gone\\htmlrtf0 kept}"; + assert_eq!(rtf_html(rtf, 100).unwrap().unwrap().0, "kept"); +} + +#[test] +fn only_a_document_that_says_so_is_html() { + assert_eq!(rtf_html(b"{\\rtf1\\ansi plain}", 100).unwrap(), None); + assert_eq!(rtf_html(b"{\\rtf1\\fromtext hello}", 100).unwrap(), None); + assert_eq!(rtf_html(b"{\\rtf1 text\\fromhtml1 }", 100).unwrap(), None, "past a token that isn't a control word"); + assert_eq!(rtf_html(b"{\\rtf1\\a\\b\\c\\d\\e\\f\\g\\h\\i\\j\\fromhtml1 }", 100).unwrap(), None, "past the first ten tokens"); + assert_eq!(rtf_html(b"not rich text", 100).unwrap(), None); +} + +#[test] +fn encapsulated_html_is_capped_and_a_deep_nest_refused() { + let mut rtf = b"{\\rtf1\\fromhtml1 ".to_vec(); + rtf.extend(std::iter::repeat_n(b'x', 1000)); + let (html, cut) = rtf_html(&rtf, 10).unwrap().unwrap(); + assert!(cut && html.len() <= 10); + let mut deep = b"{\\rtf1\\fromhtml1 ".to_vec(); + deep.extend(std::iter::repeat_n(b'{', MAX_RTF_DEPTH + 2)); + assert!(rtf_html(&deep, 100).is_err()); +} + +#[test] +fn the_text_of_encapsulated_html_is_still_what_it_shows() { + let rtf = b"{\\rtf1\\fromhtml1 {\\*\\htmltag

}\\htmlrtf x\\htmlrtf0 hello}"; + assert_eq!(rtf_text(rtf, 100).unwrap().0, "xhello"); +} + +proptest! { + #[test] + fn any_bytes_read_as_html_never_panic(data in proptest::collection::vec(any::(), 0..2000)) { + let mut rtf = b"{\\rtf1\\fromhtml1 ".to_vec(); + rtf.extend_from_slice(&data); + let _ = rtf_html(&rtf, 1 << 12); + } +} + +/// The font character sets are the crate's table, but for ANSI (0), which is Windows-1252 +/// whatever the document's code page, and the sets with no text code page here. +#[test] +fn font_character_sets_name_their_code_pages() { + let want = [ + (0, 1252), + (77, 10000), + (128, 932), + (129, 949), + (134, 936), + (136, 950), + (161, 1253), + (162, 1254), + (163, 1258), + (177, 1255), + (178, 1256), + (186, 1257), + (204, 1251), + (222, 874), + (238, 1250), + ]; + for charset in -1..=256 { + let expected = want.iter().find(|(c, _)| *c == charset).map(|(_, cp)| *cp); + assert_eq!(super::charset_code_page(charset), expected, "{charset}"); + } +} + +#[test] +fn the_checksum_is_the_specifications() { + // [MS-OXRTFCP] 3.2.3: the CRC of the compressed bytes of the first example is its header's. + assert_eq!(crc32(&EXAMPLE_1[16..]), u32::from_le_bytes([0xf1, 0xc5, 0xc7, 0xa7])); + assert_eq!(crc32(b""), 0); +}