From 2ac978ce54d32770049d8dc81dbb8ecc01d81c9f Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:49:53 -0500 Subject: [PATCH 1/2] dc5-02: enforce clipboard HTML size cap --- crates/engine/src/cmd/richclip.rs | 114 ++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs index 5b9eaf6d..063bd392 100644 --- a/crates/engine/src/cmd/richclip.rs +++ b/crates/engine/src/cmd/richclip.rs @@ -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("
"); + let prefix = if i > 0 { "

" } else { "

" }; + if !push_if_fits(&mut out, prefix, 4) { + return plain_html(body); } - out.push_str("

"); 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("
"), - _ => push_escaped(&mut out, &r.text), + deckcraft_model::text::RunKind::Break => { + if !push_if_fits(&mut out, "
", 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("

"); } 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 { "

" } else { "

" }; + 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, "
", 4); + } else { + complete = push_escaped_bounded(&mut out, &r.text, 4); + } + if !complete { + break; + } + } + out.push_str("

"); + 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, "&", reserve) { + return false; + } + } + '<' => { + if !push_if_fits(out, "<", reserve) { + return false; + } + } + '>' => { + if !push_if_fits(out, ">", 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("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - _ => out.push(c), - } - } -} - /// The formatted text of an HTML fragment, or `None` when it has no text. /// /// Understands ``, ``, ``, ``, ``, `
`, 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("

")); + 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(); From 4d2c1a3bac16b5f96b66305e73f8128ccfbb9f74 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 16:00:36 -0400 Subject: [PATCH 2/2] Clipboard HTML: put no HTML on the clipboard when even plain text is over the cap Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/richclip.rs | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs index 063bd392..6e62b67d 100644 --- a/crates/engine/src/cmd/richclip.rs +++ b/crates/engine/src/cmd/richclip.rs @@ -42,28 +42,26 @@ pub fn body_to_html(body: &TextBody) -> String { out } +/// The text without formatting, or "" (no HTML on the clipboard) when even that is too big: a +/// cut-off prefix would paste as if it were the whole text. fn plain_html(body: &TextBody) -> String { let mut out = String::new(); for (i, p) in body.paragraphs.iter().enumerate() { let prefix = if i > 0 { "

" } else { "

" }; if !push_if_fits(&mut out, prefix, 4) { - break; + return String::new(); } - let mut complete = true; for r in &p.runs { - if matches!(&r.kind, deckcraft_model::text::RunKind::Break) { - complete = push_if_fits(&mut out, "
", 4); + let fits = if matches!(&r.kind, deckcraft_model::text::RunKind::Break) { + push_if_fits(&mut out, "
", 4) } else { - complete = push_escaped_bounded(&mut out, &r.text, 4); - } - if !complete { - break; + push_escaped_bounded(&mut out, &r.text, 4) + }; + if !fits { + return String::new(); } } out.push_str("

"); - if !complete { - break; - } } out } @@ -496,18 +494,13 @@ mod tests { } #[test] - fn oversized_escaped_rich_text_falls_back_to_bounded_plain_html() { + fn oversized_escaped_rich_text_puts_no_html_on_the_clipboard() { 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("

")); - 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("&<🦀")); + // Neither the formatted nor the plain HTML fits, and a truncated prefix would lose text. + assert!(body_to_html(&body).is_empty()); } #[test]