From 054485a3f9163861929f7a910a51b12a1e10430a Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Mon, 18 May 2026 14:38:53 +0800 Subject: [PATCH 1/2] Fix Row::clear_wide panic on truncated wide character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Row::resize shrinks a row past the continuation cell of a wide character, the row's last cell is left flagged as is_wide() with no valid continuation. The next CSI K (erase in line) walks the row via Row::erase -> Row::clear_wide, which indexes cells[col + 1] without a bounds check and panics: thread '...' panicked at src/row.rs: index out of bounds: the len is N but the index is N The minimal repro from #28: let mut parser = vt100::Parser::new(2, 4, 0); parser.process("你".as_bytes()); parser.screen_mut().set_size(2, 1); parser.process(b"\x1b[K"); Two changes, both defensive — either alone prevents the panic, both together cover any other path that could leave an orphaned wide cell: 1. Row::resize: when shrinking, clear a wide char whose continuation was just truncated. Mirrors what Row::truncate already does. 2. Row::clear_wide: bounds-check before accessing col + 1; if the continuation cell is gone, clear the wide cell in place rather than indexing past the end. Adds a regression test (the issue's minimal repro) to tests/basic.rs. Closes #28. --- src/row.rs | 26 ++++++++++++++++++++++++-- tests/basic.rs | 13 +++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/row.rs b/src/row.rs index 4ae8d86..21fd88b 100644 --- a/src/row.rs +++ b/src/row.rs @@ -71,8 +71,19 @@ impl Row { } pub fn resize(&mut self, len: u16, cell: crate::Cell) { + let old_len = self.cells.len(); self.cells.resize(usize::from(len), cell); self.wrapped = false; + // When shrinking, a wide char at the new last position may + // have lost its continuation cell — clear it to avoid + // dangling state. Matches what Row::truncate already does. + let new_len = usize::from(len); + if new_len > 0 && new_len < old_len { + let last_cell = &mut self.cells[new_len - 1]; + if last_cell.is_wide() { + last_cell.clear(*last_cell.attrs()); + } + } } pub fn wrap(&mut self, wrap: bool) { @@ -84,9 +95,20 @@ impl Row { } pub fn clear_wide(&mut self, col: u16) { - let cell = &self.cells[usize::from(col)]; + let col_idx = usize::from(col); + let cell = &self.cells[col_idx]; let other = if cell.is_wide() { - &mut self.cells[usize::from(col + 1)] + let next = col_idx + 1; + if next >= self.cells.len() { + // Wide char at the last column with no continuation + // (e.g. a shrinking resize that split the pair). + // Clear the cell in place instead of indexing past + // the end. + let attrs = *self.cells[col_idx].attrs(); + self.cells[col_idx].clear(attrs); + return; + } + &mut self.cells[next] } else if cell.is_wide_continuation() { &mut self.cells[usize::from(col - 1)] } else { diff --git a/tests/basic.rs b/tests/basic.rs index ecc36d9..eaa9286 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -122,3 +122,16 @@ fn cell_attrs() { assert!(parser.screen().cell(0, 4).unwrap().italic()); } + +// Regression test for the panic where shrinking a screen truncates the +// continuation cell of a wide character, then erasing the line tries +// to clear past the end of the row. The repro is from +// https://github.com/doy/vt100-rust/issues/28. +#[test] +fn resize_truncating_wide_char_then_erase_line_does_not_panic() { + let mut parser = vt100::Parser::new(2, 4, 0); + parser.process("你".as_bytes()); + parser.screen_mut().set_size(2, 1); + parser.process(b"\x1b[K"); + assert_eq!(parser.screen().size(), (2, 1)); +} From 131a75e72d9da43d503772578dc1f1c332befbba Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Fri, 29 May 2026 10:58:06 +0800 Subject: [PATCH 2/2] Guard Row::erase against underflow on an orphaned wide cell clear_wide was hardened to handle an orphaned wide cell (wide flag set with no continuation), but its caller erase still derived the logical last column as cols() - if wide { 2 } else { 1 } from the pre-erase wide flag. For such a cell in a one-column row that is 1 - 2, which underflows and panics. Use saturating_sub; behaviour is unchanged for every row with two or more columns. Add an in-crate unit test for the one-column orphaned-wide case. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/row.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/row.rs b/src/row.rs index 21fd88b..9006b9a 100644 --- a/src/row.rs +++ b/src/row.rs @@ -56,7 +56,11 @@ impl Row { let wide = self.cells[usize::from(i)].is_wide(); self.clear_wide(i); self.cells[usize::from(i)].clear(attrs); - if i == self.cols() - if wide { 2 } else { 1 } { + // `wide` is captured pre-erase on purpose: a 2-wide char occupies the + // logical last column at cols() - 2. saturating_sub guards the + // degenerate case of an orphaned wide cell in a one-column row, where + // cols() - 2 would underflow. + if i == self.cols().saturating_sub(if wide { 2 } else { 1 }) { self.wrapped = false; } } @@ -494,3 +498,29 @@ impl Row { (prev_pos, prev_attrs) } } + +#[cfg(test)] +mod tests { + use super::*; + + // An orphaned wide cell (wide flag set, no continuation) in a + // one-column row must not make Row::erase underflow when it derives the + // logical last column as cols() - 2. Covers the Copilot review note on + // the clear_wide OOB fix: clear_wide itself was hardened, but its caller + // erase computed cols() - 2 == 1 - 2 from the captured `wide` value. + #[test] + fn erase_wide_cell_in_one_column_row_does_not_underflow() { + let attrs = crate::attrs::Attrs::default(); + let mut row = Row::new(1); + // A 2-wide char in the only column: a wide cell with no room for a + // continuation cell. + row.get_mut(0).unwrap().set('中', attrs); + assert!(row.get(0).unwrap().is_wide()); + + row.erase(0, attrs); + + let cell = row.get(0).unwrap(); + assert!(!cell.is_wide(), "erased cell must no longer be wide"); + assert!(!cell.has_contents(), "erased cell must be empty"); + } +}