From 6ea957ec5dcc2b54420e22eb238e1199f1fbbd09 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:36:06 +0300 Subject: [PATCH] doc: runs of a file that reach past the text can't overflow a text edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StyleRun start/end are usize taken straight from a .figcraft document.json (nothing clamps them on load; only the REST and SVG importers normalize), so TextData::replace_range's p - removed + inserted panics in a debug or test build and wraps to zero in release, where normalize then drops the run and the layer silently loses its formatting. Shift the runs with saturating arithmetic; normalize drops whatever is left. Regression test: text_runs_past_the_text_never_overflow — panics at crates/doc/src/text.rs:605 on main, passes with the clamp. --- crates/doc/src/tests.rs | 21 +++++++++++++++++++++ crates/doc/src/text.rs | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index d54bb29b..bb03a440 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() { assert_eq!(t.characters, ""); } +/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load) +/// must not overflow the arithmetic that shifts the runs along, and must not leave the +/// text unwritable. +#[test] +fn text_runs_past_the_text_never_overflow() { + let style = || TextStyle { font_style: "Bold".into(), ..Default::default() }; + for end in [usize::MAX, usize::MAX - 1, 1_000_000] { + let mut t = TextData::new("hi"); + t.runs = vec![StyleRun { start: 0, end, style: style() }]; + // Typing at the caret, and replacing a selection: both shift `end`. + t.replace_range(0, 0, "x"); + assert_eq!(t.characters, "xhi", "end {end}"); + t.replace_range(0, 3, "yo"); + assert_eq!(t.characters, "yo", "end {end}"); + // The text is still writable and the runs are back inside it. + t.replace_range(0, 0, "!"); + assert_eq!(t.characters, "!yo", "end {end}"); + assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs); + } +} + #[test] fn text_case() { assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World"); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609d..b20512f7 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -602,7 +602,9 @@ impl TextData { if p <= start { p } else if p >= end { - p - removed + inserted + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) } else { start + inserted }