Skip to content

Commit a6c8f8d

Browse files
committed
feat: mimeparser: Omit Legacy Display Elements (#7130)
Omit Legacy Display Elements from "text/plain" and "text/html" (implement 4.5.3.{2,3} of https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for Cryptographically Protected Email").
1 parent fc30aa9 commit a6c8f8d

File tree

7 files changed

+140
-18
lines changed

7 files changed

+140
-18
lines changed

src/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,11 @@ pub enum Config {
438438
/// storing the same token multiple times on the server.
439439
EncryptedDeviceToken,
440440

441+
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
442+
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
443+
/// using this still run unmodified code.
444+
TestHooks,
445+
441446
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
442447
FailOnReceivingFullMsg,
443448
}

src/context.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,17 @@ pub struct InnerContext {
303303
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
304304
/// see [`Context::get_connectivity()`].
305305
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
306+
307+
#[expect(clippy::type_complexity)]
308+
/// Transforms the root of the cryptographic payload before encryption.
309+
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
310+
Option<
311+
for<'a> fn(
312+
&Context,
313+
mail_builder::mime::MimePart<'a>,
314+
) -> mail_builder::mime::MimePart<'a>,
315+
>,
316+
>,
306317
}
307318

308319
/// The state of ongoing process.
@@ -467,6 +478,7 @@ impl Context {
467478
iroh: Arc::new(RwLock::new(None)),
468479
self_fingerprint: OnceLock::new(),
469480
connectivities: parking_lot::Mutex::new(Vec::new()),
481+
pre_encrypt_mime_hook: None.into(),
470482
};
471483

472484
let ctx = Context {
@@ -1051,6 +1063,13 @@ impl Context {
10511063
.await?
10521064
.to_string(),
10531065
);
1066+
res.insert(
1067+
"test_hooks",
1068+
self.sql
1069+
.get_raw_config("test_hooks")
1070+
.await?
1071+
.unwrap_or_default(),
1072+
);
10541073
res.insert(
10551074
"fail_on_receiving_full_msg",
10561075
self.sql

src/dehtml.rs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use quick_xml::{
1313

1414
use crate::simplify::{SimplifiedText, simplify_quote};
1515

16+
#[derive(Default)]
1617
struct Dehtml {
1718
strbuilder: String,
1819
quote: String,
@@ -25,6 +26,9 @@ struct Dehtml {
2526
/// Everything between `<div name="quote">` and `<div name="quoted-content">` is usually metadata
2627
/// If this is > `0`, then we are inside a `<div name="quoted-content">`.
2728
divs_since_quoted_content_div: u32,
29+
/// `<div class="header-protection-legacy-display">` elements should be omitted, see
30+
/// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>.
31+
divs_since_hp_legacy_display: u32,
2832
/// All-Inkl just puts the quote into `<blockquote> </blockquote>`. This count is
2933
/// increased at each `<blockquote>` and decreased at each `</blockquote>`.
3034
blockquotes_since_blockquote: u32,
@@ -48,20 +52,25 @@ impl Dehtml {
4852
}
4953

5054
fn get_add_text(&self) -> AddText {
51-
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0 {
52-
AddText::No // Everything between `<div name="quoted">` and `<div name="quoted_content">` is metadata which we don't want
55+
// Everything between `<div name="quoted">` and `<div name="quoted_content">` is
56+
// metadata which we don't want.
57+
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0
58+
|| self.divs_since_hp_legacy_display > 0
59+
{
60+
AddText::No
5361
} else {
5462
self.add_text
5563
}
5664
}
5765
}
5866

59-
#[derive(Debug, PartialEq, Clone, Copy)]
67+
#[derive(Debug, Default, PartialEq, Clone, Copy)]
6068
enum AddText {
6169
/// Inside `<script>`, `<style>` and similar tags
6270
/// which contents should not be displayed.
6371
No,
6472

73+
#[default]
6574
YesRemoveLineEnds,
6675

6776
/// Inside `<pre>`.
@@ -121,12 +130,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
121130

122131
let mut dehtml = Dehtml {
123132
strbuilder: String::with_capacity(buf.len()),
124-
quote: String::new(),
125-
add_text: AddText::YesRemoveLineEnds,
126-
last_href: None,
127-
divs_since_quote_div: 0,
128-
divs_since_quoted_content_div: 0,
129-
blockquotes_since_blockquote: 0,
133+
..Default::default()
130134
};
131135

132136
let mut reader = quick_xml::Reader::from_str(buf);
@@ -244,6 +248,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
244248
"div" => {
245249
pop_tag(&mut dehtml.divs_since_quote_div);
246250
pop_tag(&mut dehtml.divs_since_quoted_content_div);
251+
pop_tag(&mut dehtml.divs_since_hp_legacy_display);
247252

248253
*dehtml.get_buf() += "\n\n";
249254
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -295,6 +300,8 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
295300
"div" => {
296301
maybe_push_tag(event, reader, "quote", &mut dehtml.divs_since_quote_div);
297302
maybe_push_tag(event, reader, "quoted-content", &mut dehtml.divs_since_quoted_content_div);
303+
maybe_push_tag(event, reader, "header-protection-legacy-display",
304+
&mut dehtml.divs_since_hp_legacy_display);
298305

299306
*dehtml.get_buf() += "\n\n";
300307
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -539,6 +546,27 @@ mod tests {
539546
assert_eq!(txt.text.trim(), "two\nlines");
540547
}
541548

549+
#[test]
550+
fn test_hp_legacy_display() {
551+
let input = r#"
552+
<html><head><title></title></head><body>
553+
<div class="header-protection-legacy-display">
554+
<pre>Subject: Dinner plans</pre>
555+
</div>
556+
<p>
557+
Let's meet at Rama's Roti Shop at 8pm and go to the park
558+
from there.
559+
</p>
560+
</body>
561+
</html>
562+
"#;
563+
let txt = dehtml(input).unwrap();
564+
assert_eq!(
565+
txt.text.trim(),
566+
"Let's meet at Rama's Roti Shop at 8pm and go to the park from there."
567+
);
568+
}
569+
542570
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
543571
async fn test_quote_div() {
544572
let input = include_str!("../test-data/message/gmx-quote-body.eml");

src/mimefactory.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,12 @@ impl MimeFactory {
12321232
// once new core versions are sufficiently deployed.
12331233
let anonymous_recipients = false;
12341234

1235+
if context.get_config_bool(Config::TestHooks).await? {
1236+
if let Some(hook) = &*context.pre_encrypt_mime_hook.lock() {
1237+
message = hook(context, message);
1238+
}
1239+
}
1240+
12351241
let encrypted = if let Some(shared_secret) = shared_secret {
12361242
encrypt_helper
12371243
.encrypt_symmetrically(context, &shared_secret, message, compress)

src/mimeparser.rs

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,10 @@ impl MimeMessage {
13231323
let is_html = mime_type == mime::TEXT_HTML;
13241324
if is_html {
13251325
self.is_mime_modified = true;
1326+
// NB: This unconditionally removes Legacy Display Elements (see
1327+
// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>). We
1328+
// don't check for the "hp-legacy-display" Content-Type parameter
1329+
// for simplicity.
13261330
if let Some(text) = dehtml(&decoded_data) {
13271331
text
13281332
} else {
@@ -1350,16 +1354,30 @@ impl MimeMessage {
13501354

13511355
let (simplified_txt, simplified_quote) = if mime_type.type_() == mime::TEXT
13521356
&& mime_type.subtype() == mime::PLAIN
1353-
&& is_format_flowed
13541357
{
1355-
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
1356-
delsp.as_str().eq_ignore_ascii_case("yes")
1357-
} else {
1358-
false
1358+
// Don't check that we're inside an encrypted or signed part for
1359+
// simplicity.
1360+
let simplified_txt = match mail
1361+
.ctype
1362+
.params
1363+
.get("hp-legacy-display")
1364+
.is_some_and(|v| v == "1")
1365+
{
1366+
false => simplified_txt,
1367+
true => rm_legacy_display_elements(&simplified_txt),
13591368
};
1360-
let unflowed_text = unformat_flowed(&simplified_txt, delsp);
1361-
let unflowed_quote = top_quote.map(|q| unformat_flowed(&q, delsp));
1362-
(unflowed_text, unflowed_quote)
1369+
if is_format_flowed {
1370+
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
1371+
delsp.as_str().eq_ignore_ascii_case("yes")
1372+
} else {
1373+
false
1374+
};
1375+
let unflowed_text = unformat_flowed(&simplified_txt, delsp);
1376+
let unflowed_quote = top_quote.map(|q| unformat_flowed(&q, delsp));
1377+
(unflowed_text, unflowed_quote)
1378+
} else {
1379+
(simplified_txt, top_quote)
1380+
}
13631381
} else {
13641382
(simplified_txt, top_quote)
13651383
};
@@ -1981,6 +1999,20 @@ impl MimeMessage {
19811999
}
19822000
}
19832001

2002+
fn rm_legacy_display_elements(text: &str) -> String {
2003+
let mut res = None;
2004+
for l in text.lines() {
2005+
res = res.map(|r: String| match r.is_empty() {
2006+
true => l.to_string(),
2007+
false => r + "\r\n" + l,
2008+
});
2009+
if l.is_empty() {
2010+
res = Some(String::new());
2011+
}
2012+
}
2013+
res.unwrap_or_default()
2014+
}
2015+
19842016
fn remove_header(
19852017
headers: &mut HashMap<String, String>,
19862018
key: &str,

src/mimeparser/mimeparser_tests.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,6 +1751,39 @@ async fn test_time_in_future() -> Result<()> {
17511751
Ok(())
17521752
}
17531753

1754+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1755+
async fn test_hp_legacy_display() -> Result<()> {
1756+
let mut tcm = TestContextManager::new();
1757+
let alice = &tcm.alice().await;
1758+
let bob = &tcm.bob().await;
1759+
1760+
let mut msg = Message::new_text(
1761+
"Subject: Dinner plans\n\
1762+
\n\
1763+
Let's eat"
1764+
.to_string(),
1765+
);
1766+
msg.set_subject("Dinner plans".to_string());
1767+
let chat_id = alice.create_chat(bob).await.id;
1768+
alice.set_config_bool(Config::TestHooks, true).await?;
1769+
*alice.pre_encrypt_mime_hook.lock() = Some(|_, mut mime| {
1770+
for (h, v) in &mut mime.headers {
1771+
if h == "Content-Type" {
1772+
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
1773+
*ct = ct.clone().attribute("hp-legacy-display", "1");
1774+
}
1775+
}
1776+
}
1777+
mime
1778+
});
1779+
let sent_msg = alice.send_msg(chat_id, &mut msg).await;
1780+
1781+
let msg_bob = bob.recv_msg(&sent_msg).await;
1782+
assert_eq!(msg_bob.subject, "Dinner plans");
1783+
assert_eq!(msg_bob.text, "Let's eat");
1784+
Ok(())
1785+
}
1786+
17541787
/// Tests that subject is not prepended to the message
17551788
/// when bot receives it.
17561789
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

src/test_utils.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,6 @@ Until the false-positive is fixed:
16791679
}
16801680
}
16811681

1682-
#[cfg(test)]
16831682
mod tests {
16841683
use super::*;
16851684

0 commit comments

Comments
 (0)