fix(cursor): clamp Windows legacy-console cursor moves at the buffer edge - #1112
fix(cursor): clamp Windows legacy-console cursor moves at the buffer edge#1112hdimer wants to merge 2 commits into
Conversation
…edge `move_up`, `move_left` and `move_to_previous_line` in `src/cursor/sys/windows.rs` subtracted the caller's `count` from the current row/column with a bare `u16` subtraction and no floor. When `count` exceeded the current position this underflowed: a panic in a debug build, a wrap to ~65535 in release which then fails `move_to`'s bounds handling. `saturating_sub` clamps at the first row/column. That is what the ANSI path already gets from the terminal for CUU/CUB/CPL, and it is the primitive the crate already chose for the same bug class in `src/event/sys/unix/parse.rs`. The existing tests moved back by the exact count, so they never crossed zero. Each now also overshoots from a non-zero position, which pins clamping rather than merely not-underflowing. Closes crossterm-rs#1094.
joshka
left a comment
There was a problem hiding this comment.
Thanks for the PR. I understand there's concerns about i16 casts and that's why you skipped the move_right/down fixes. I think it's worth getting this right in a single PR rather than two PRs or skipping the overflow condition here.
Would you mind taking a look and expanding on this?
There was a problem hiding this comment.
This seems like it would similarly overflow - same move_down.
move_right, move_down and move_to_next_line added the caller's count to the current column/row with a bare u16 add: panic in a debug build, wrap in release. Even short of the overflow, any target past the screen buffer was rejected by SetConsoleCursorPosition. They now clamp at the last addressable cell of the screen buffer, which is also what keeps the u16 -> i16 cast in move_to in range. Clamping at the buffer rather than the window edge means only moves that previously failed change outcome.
|
Done in 4ebbaec — The clamp is at the last cell of the screen buffer, not the window. That is the bound That also settles the One behaviour change beyond the overflow, worth flagging: Each of the three tests grew an overshoot case. They assert the cursor moved past where it started rather than a fixed index: CI's buffer size is not knowable from here, and conhost scrolls the window when the cursor goes below it, after which Verification as before: the tests run on your Windows CI, not here. Locally, cross-target clippy with Two things next door that I found and left alone: |
Closes #1094.
The bug
move_up,move_leftandmove_to_previous_lineinsrc/cursor/sys/windows.rssubtract the caller's
countfrom the current row/column with a bareu16subtraction and no floor:
When
countexceeds the current row/column that underflows:attempt to subtract with overflowin a debug build, or a wrap to a largeu16in release. This is thelegacy-console path only, reached through
execute_winapiwhensupports_ansiisfalse, so it does not affect the ANSI path.
MoveUp(n)withngreater than the current row is not really a caller error, itis the ordinary "get back to the top of the block I just drew" idiom, and it does
the right thing everywhere except here.
Scope: three sites, not the two in the issue
#1094 names
move_upandmove_leftand saysmove_to_previous_lineis alreadyguarded. It isn't.
MoveToPreviousLine::execute_winapiinsrc/cursor.rsonlychecks
if self.0 != 0, which skips the zero case, not thecount > rowcase, sosys::move_to_previous_lineunderflows the same way. All three are fixed here.One correction to the issue while I'm at it: the release-mode outcome is not
uniformly the
Argument Out of Rangeerror it describes. That happens for countsin
1..=32768, where the wrapped value casts to a negativei16and trips theguard in
ScreenBufferCursor::move_to. A larger count wraps to a positivei16, sails past the guard, and fails later against the real buffer bounds with adifferent OS error. Either way it is wrong, but the error you get varies.
Why clamp
execute_winapiexists to emulate the ANSI sequence on legacy conhost, so the ANSIbranch is the spec.
MoveUp/MoveLeft/MoveToPreviousLineemit CUU/CUB/CPL, whichterminals clamp at the top row and first column. Returning an error would make the
WinAPI path diverge from the branch it is imitating, and doing nothing would be
wrong on its own terms (moving up 5 from row 2 should land on row 0, not stay on
row 2).
saturating_subis also the primitive the crate already picked for thisexact bug class in
src/event/sys/unix/parse.rs.What is deliberately not in here
move_down,move_rightandmove_to_next_lineadd rather than subtract, so theycan overflow rather than underflow. I left them alone on purpose:
saturating_addwould clamp at
u16::MAX, which theas i16cast turns straight back into anerror, so it converts a panic into a different failure rather than fixing anything.
A real fix there needs the screen buffer bounds and a decision about whether to
clamp to the buffer or the window, which is a separate question from this one.
Tests
The three existing tests moved back by the exact count (
move_to(0, 2)thenmove_up(2)), so they landed on zero without ever crossing it and never touchedthe bug. Each now also overshoots. The overshoot deliberately starts from a
non-zero position rather than from the origin: starting at zero, "clamp at the
edge" and "do nothing" are indistinguishable, so such a test would still pass if
the fix were later replaced with
checked_sub(count).unwrap_or(row). Starting tworows down and asking for five pins the clamp itself.
Verification
This is
cfg(windows)code needing a real console handle, and I am on macOS, sothe three tests here run on your Windows CI, not on my machine. What I did check
locally:
cargo clippy --locked --target x86_64-pc-windows-msvc --all-targets --all-features -- -D warnings, clean. I confirmed--all-targetsreally does type-check the#[cfg(test)]module for that target by injecting a deliberate type error into one of the new test lines and watching it fail.rustup run 1.85.0 cargo check --locked --lib --all-features --target x86_64-pc-windows-msvc, clean, plus both host MSRV configurations.cargo fmt --check, host clippy,cargo test --locked --all-targets --all-features -- --test-threads 1(121 passed), doc tests,cargo docwithRUSTDOCFLAGS=-D warnings,cargo package.functions over a stub cursor and ran the new assertions against three
implementations: the current bare subtraction fails all three (panic in debug,
error in release),
checked_sub(..).unwrap_or(..)fails all three, andsaturating_subpasses in both profiles.I did not run
cargo deny,actionlintorzizmor; this touches no dependencyand no workflow file.
I used an AI assistant while working on this. The reasoning above, the scope
correction and the test design are mine and I have checked them; the diff is small
enough to read in a minute either way.
Disclosure: this change was prepared with AI assistance (Claude Code). The repro and tests described above were run before it was opened.