dc5-02: enforce clipboard HTML size cap

This commit is contained in:
Harrison Booth
2026-10-28 14:49:53 -05:00
parent f893f6af7d
commit 2ac978ce54
+94 -20
View File
@@ -14,27 +14,97 @@ const MAX_RUNS: usize = 10_000;
pub fn body_to_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
if i > 0 {
out.push_str("<br>");
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
if !push_if_fits(&mut out, prefix, 4) {
return plain_html(body);
}
out.push_str("<p>");
for r in &p.runs {
let (open, close) = tags(&r.props);
out.push_str(&open);
if !push_if_fits(&mut out, &open, close.len() + 4) {
return plain_html(body);
}
match &r.kind {
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
_ => push_escaped(&mut out, &r.text),
deckcraft_model::text::RunKind::Break => {
if !push_if_fits(&mut out, "<br>", close.len() + 4) {
return plain_html(body);
}
}
_ => {
if !push_escaped_bounded(&mut out, &r.text, close.len() + 4) {
return plain_html(body);
}
}
}
out.push_str(&close);
if out.len() > MAX_HTML {
break;
}
}
out.push_str("</p>");
}
out
}
fn plain_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
if !push_if_fits(&mut out, prefix, 4) {
break;
}
let mut complete = true;
for r in &p.runs {
if matches!(&r.kind, deckcraft_model::text::RunKind::Break) {
complete = push_if_fits(&mut out, "<br>", 4);
} else {
complete = push_escaped_bounded(&mut out, &r.text, 4);
}
if !complete {
break;
}
}
out.push_str("</p>");
if !complete {
break;
}
}
out
}
fn push_if_fits(out: &mut String, value: &str, reserve: usize) -> bool {
if out.len().checked_add(value.len()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
return false;
}
out.push_str(value);
true
}
fn push_escaped_bounded(out: &mut String, text: &str, reserve: usize) -> bool {
for c in text.chars() {
match c {
'&' => {
if !push_if_fits(out, "&amp;", reserve) {
return false;
}
}
'<' => {
if !push_if_fits(out, "&lt;", reserve) {
return false;
}
}
'>' => {
if !push_if_fits(out, "&gt;", reserve) {
return false;
}
}
_ => {
if out.len().checked_add(c.len_utf8()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
return false;
}
out.push(c);
}
}
}
true
}
fn tags(props: &RunProps) -> (String, String) {
let mut open = String::new();
let mut close = String::new();
@@ -53,17 +123,6 @@ fn tags(props: &RunProps) -> (String, String) {
(open, close)
}
fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(c),
}
}
}
/// The formatted text of an HTML fragment, or `None` when it has no text.
///
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
@@ -436,6 +495,21 @@ mod tests {
assert!(last.props.underline.is_some());
}
#[test]
fn oversized_escaped_rich_text_falls_back_to_bounded_plain_html() {
let mut body = TextBody::from_text(&"&<🦀".repeat(100_000));
body.paragraphs[0].runs[0].props.bold = Some(true);
body.paragraphs.push(Paragraph::new("later paragraph"));
let html = body_to_html(&body);
assert!(html.len() <= MAX_HTML);
assert!(html.ends_with("</p>"));
let parsed = html_to_body(&html).expect("exported fallback must be valid HTML");
assert_eq!(parsed.paragraphs[0].runs[0].props.bold, None);
assert_eq!(parsed.paragraphs.len(), 1);
assert!(parsed.text().starts_with("&<🦀"));
}
#[test]
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
let text = |html: &str| html_to_body(html).unwrap().text();