From 013941e8e83383f676dbc60418961de585b3969c Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:19:52 +0300 Subject: [PATCH] engine: a hostile text selection can't drive paragraph commands past the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paragraph commands (align, case, insert, paste, selectParagraph) iterated for pi in a.0..=b.0 with both ends taken verbatim from text.select, so a control-channel selection with caret u64::MAX spun the loop over 2^64 entries on the UI thread, and text.selectParagraph's at.0 + 1 overflow- panicked in debug builds. Clamp every range at the body's last paragraph and use saturating_add for the single-index arithmetic. Regression test: paragraph_commands_clamp_a_hostile_text_selection — fails on main with 'attempt to add with overflow' at cmd/text.rs:1151, passes with the clamp. --- crates/engine/src/cmd/format.rs | 3 ++- crates/engine/src/cmd/text.rs | 13 ++++++++----- crates/engine/src/tests.rs | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 36d54de9..21b5474a 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -692,7 +692,8 @@ fn case(s: &mut Session, p: &Value) -> Result { } let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() }; super::text::edit_text(s, move |body, t| { - for pi in a.0..=b.0 { + let last = body.paragraphs.len().saturating_sub(1); + for pi in a.0..=b.0.min(last) { let Some(para) = body.paragraphs.get_mut(pi) else { continue }; let n = para.char_len(); let from = if pi == a.0 { a.1 } else { 0 }; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a0408eff..2fd959c0 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -417,7 +417,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) let c = ed::insert(body, at, &text, props); t.anchor = c; t.caret = c; - for pi in at.0..=c.0 { + let last = body.paragraphs.len().saturating_sub(1); + for pi in at.0..=c.0.min(last) { if let Some(para) = body.paragraphs.get_mut(pi) { promote_rtl_if_needed(para, &inherited); } @@ -741,7 +742,8 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { let c = ed::insert_body(body, at, src); t.anchor = c; t.caret = c; - for pi in at.0..=c.0 { + let last = body.paragraphs.len().saturating_sub(1); + for pi in at.0..=c.0.min(last) { if let Some(para) = body.paragraphs.get_mut(pi) { promote_rtl_if_needed(para, &inherited); } @@ -1081,7 +1083,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { } None => match to.as_str() { "up" => (caret.0.saturating_sub(1), caret.1), - "down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1), + "down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1), "lineStart" => (caret.0, 0), _ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), }, @@ -1148,7 +1150,7 @@ fn select_para(s: &mut Session, p: &Value) -> Result { let body = body_of(st, &t); let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); // The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too). - let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) }; + let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) }; s.select(|_, sel| { if let Some(t) = sel.text.as_mut() { t.anchor = (at.0, 0); @@ -1328,7 +1330,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex if body.paragraphs.is_empty() { body.paragraphs.push(Default::default()); } - for pi in a.0..=b.0 { + let last = body.paragraphs.len().saturating_sub(1); + for pi in a.0..=b.0.min(last) { if let Some(p) = body.paragraphs.get_mut(pi) { f(p); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f7022eab..da2c63c3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3592,3 +3592,17 @@ fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() { assert_eq!(t.n_cols(), 3); assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place"); } + +#[test] +fn paragraph_commands_clamp_a_hostile_text_selection() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap(); + s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})).unwrap(); + s.execute("format.alignCenter", &json!({})).unwrap(); + s.execute("format.case", &json!({"mode": "upper"})).unwrap(); + s.execute("text.insert", &json!({"text": "A"})).unwrap(); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert!(t.caret.0 < 1_000, "the caret stayed inside the body: {t:?}"); +}