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>
This commit is contained in:
Dylan M. Taylor
2026-11-11 19:25:27 -05:00
committed by GitHub
co-authored by Martin Ball
parent 219f63f64c
commit 53e1717523
6 changed files with 1121 additions and 0 deletions
Generated
+2
View File
@@ -1556,7 +1556,9 @@ dependencies = [
name = "craft-rtf"
version = "0.1.0"
dependencies = [
"craft-mime",
"encoding_rs",
"proptest",
]
[[package]]
+10
View File
@@ -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 }
+11
View File
@@ -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,
+2
View File
@@ -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;
+711
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<u32> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> = 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<i64>,
/// 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<u8>,
/// The code page of `pending`.
pending_cp: u32,
high: Option<u16>,
ansicpg: Option<u32>,
mac: bool,
deff: Option<i64>,
/// `\fcharset` of each font in the font table.
fonts: HashMap<i64, i64>,
/// 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<u32> {
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<i64>) -> 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<Option<(String, bool)>> {
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<Group> = 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<i64> = 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<i64>, 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;
+385
View File
@@ -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<u8> {
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 <b>}}";
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 <html>}\\htmlrtf {\\htmlrtf0 Hello {\\*\\htmltag84 <b>}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 &uml; (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 &uml;}\\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<u8> = [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::<u8>(), 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::<u8>(), 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 <p>}{\\htmlrtf \\f1 \\htmlrtf0 caf\\'e9 {\\*\\htmltag <b>}{\\htmlrtf \\b \\htmlrtf0 bold{\\*\\htmltag </b>}}\
\\htmlrtf \\par \\htmlrtf0 {\\*\\htmltag &amp;}\\htmlrtf &\\htmlrtf0 \\u8364?{\\*\\mhtmltag <skipped>}{\\*\\other no}{\\*\\htmltag </p>}}}";
#[test]
fn encapsulated_html_comes_out_as_it_was() {
let (html, cut) = rtf_html(ENCAPSULATED, 1 << 16).unwrap().unwrap();
assert!(!cut);
assert_eq!(html, "<p>caf\u{439} <b>bold</b>&amp;\u{20AC}</p>");
}
#[test]
fn a_tag_is_kept_even_where_the_text_round_it_is_suppressed() {
let rtf = b"{\\rtf1\\fromhtml1 \\htmlrtf {\\*\\htmltag <i>}gone\\htmlrtf0 kept}";
assert_eq!(rtf_html(rtf, 100).unwrap().unwrap().0, "<i>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 <p>}\\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::<u8>(), 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);
}