Selkie.git | lib/Selkie/App/Internal/ | TerminalReport.rakumod
=begin pod
=head1 NAME
Selkie::App::Internal::TerminalReport - recognise fragmented terminal reports
=head1 DESCRIPTION
Implementation detail for C<Selkie::App>'s input dispatch. Application
code never calls this; see the C<Fragmented terminal reports> section
of C<Selkie::App> for the user-facing description of what it defends
against.
=head2 The problem
A terminal answers capability queries (DA1, DA2, CPR, XTWINOPS,
XTGETTCAP, XTVERSION, XTSMGRAPHICS, DECRPM, the kitty keyboard and
graphics protocols, OSC colour queries) by writing an escape sequence
back on the tty as if the user had typed it. notcurses recognises
those replies and swallows them — B<but only when the whole reply
lands in a single C<read(2)>>. Its own rule, from
C<src/lib/automaton.c>, is that "an escape must arrive as a single
unit to be interpreted as an escape"; a reply that straddles two reads
is treated as an aborted sequence and B<replayed to the application
verbatim, byte by byte, as ordinary keypresses>.
The gap needed to trigger that is sub-millisecond. Measured against
notcurses 3.0.17 in a pty: writing C<\e[?64;1;2;6;9;15;18;21;22c> in
one C<write(2)> is absorbed silently, while splitting it in two with a
0.5ms pause delivers 26 key events — C<Escape>, then C<[>, C<?>, C<6>,
C<4>, C<;> … C<c>. Into a focused text field, that is 25 characters of
garbage the user never typed; into a I<masked> field, 25 bullets
nobody can read back. The regime that produces the fragmentation is
exactly a saturated CPU (Cantina's post-login module warm-up child,
say) delaying the reading thread between the terminal's two writes.
=head2 The defence
C<terminal-report-prefix> is a strict recogniser for complete control
sequences that only a terminal can have produced. C<Selkie::App>'s
input dispatch runs it over the text of an C<Escape>-led input burst
and drops what it matches, so a fragmented reply is discarded instead
of being typed into whatever has focus.
Two independent properties keep it away from real keystrokes:
=item B<Shape.> A match must be C<Escape>, an introducer, a body built
only from the character class that introducer allows, and a
I<report> final byte. C<Escape> followed by ordinary text, by an
arrow key, by a bare C<[>, or by anything with a character outside
the parameter class fails to match and is dispatched untouched.
=item B<Timing.> The caller only ever offers characters that were
already sitting in notcurses's queue at one instant — pulled with
the non-blocking C<notcurses_get_nblock>, never waited for. Filling
that queue with a six-plus character control sequence between two
consecutive polls of a loop that runs at 60Hz is not something a
human hand does; a terminal answering a query does it every time.
Neither property is load-bearing alone, which is the point: matching
requires both a byte sequence nobody types and a delivery rate nobody
can type at.
=head2 Grammar
=begin code
report := ESC ( csi | string )
csi := '[' private? params intermediates final
private := one of ? > = <
params := ( digit | ';' | ':' )+ # at least one
intermediates := ( '$' | ' ' | '!' | '"' )*
final := one of c R t u S y n # any private introducer
mouse := '<' button ';' coordinate ';' coordinate ( 'M' | 'm' )
button := digit+
coordinate := '-'? digit+
string := ( 'P' | ']' | '_' | '^' ) body terminator # DCS / OSC / APC / PM
body := any run of characters containing no ESC
terminator := ESC '\' # ST
| "\a" # BEL, OSC only
=end code
C<params> being mandatory and non-empty is what excludes the cursor
keys: C<\e[A> has no parameters and C<A> is not a report final, so an
arrow key that somehow arrived in pieces is passed through rather than
eaten.
The C<M>/C<m> finals are admitted only for the exact three-field SGR
mouse shape. Coordinates may be negative: terminals can report a
pointer outside the drawable plane with sequences such as
C<\e[E<lt>0;3510;-10M>. The sign is deliberately part of the mouse
grammar, not the generic CSI parameter class. Bare C<\e[…m> is an SGR
colour sequence — output, never input — and C<\e[…M> is the X10 mouse
encoding, whose payload bytes are not restricted to the parameter
class and so cannot match anyway.
=head2 Deliberate losses
C<u> and C<R> finals are shared between terminal reports and the kitty
keyboard protocol's encoding of real keypresses (C<\e[97;2u> is
Shift+A; C<\e[1;5R> is Ctrl+F3). A fragmented kitty keypress therefore
gets dropped rather than delivered. That is the right trade: without
the filter the same fragment does not arrive as Shift+A either — it
arrives as the six literal characters C<[97;2u> inserted into the
focused widget. Losing one keystroke beats typing six.
=head2 Bounds
C<MAX-REPORT-CHARS> caps how far the recogniser will scan. A string
sequence (DCS/OSC/APC/PM) whose terminator never arrives cannot make
the scan run away over a large paste, and an unterminated fragment is
passed through as input rather than being held back.
=head1 SEE ALSO
C<Selkie::App>, C<Selkie::App::Internal::Dispatch>
=end pod
unit module Selkie::App::Internal::TerminalReport;
#|( Longest control sequence the recogniser will consider. Real
replies are far shorter — the longest in the wild is an XTGETTCAP
answer carrying a hex-encoded terminfo string, comfortably under
128 characters. The cap exists so an unterminated C<\eP…> fragment
at the head of a large paste cannot turn the scan into an O(paste)
walk on every burst. )
our constant MAX-REPORT-CHARS = 256;
#| Private-parameter introducers permitted directly after C<ESC [>.
my constant CSI-PRIVATE = set('?', '>', '=', '<');
#| Characters a CSI parameter list may be built from.
my constant CSI-PARAM = set('0'..'9', ';', ':');
#| Decimal digits used by the stricter SGR mouse grammar.
my constant CSI-DIGIT = set('0'..'9');
#| Intermediate bytes permitted between the parameters and the final.
my constant CSI-INTERMEDIATE = set('$', ' ', '!', '"');
#|( Final bytes that mark a CSI as a I<report>: C<c> device attributes,
C<R> cursor position, C<t> window manipulation, C<u> kitty keyboard
flags, C<S> XTSMGRAPHICS, C<y> DECRPM, C<n> device status. )
my constant CSI-REPORT-FINAL = set('c', 'R', 't', 'u', 'S', 'y', 'n');
#| Extra finals allowed only behind the C<E<lt>> (SGR mouse) introducer.
my constant CSI-MOUSE-FINAL = set('M', 'm');
#| Introducers for the ST-terminated string sequences.
my constant STRING-INTRODUCER = set('P', ']', '_', '^');
#|( Length in characters of the complete terminal report at the head of
C<$text>, or C<0> when C<$text> does not begin with one.
The scan is strictly prefix-anchored and never looks past
C<MAX-REPORT-CHARS>: an incomplete sequence returns C<0> exactly
like a non-matching one, so a caller that keeps the unmatched text
is keeping everything it was given.
=begin code :lang<raku>
terminal-report-prefix("\e[?64;1;2;6;9;15;18;21;22c"); # 26
terminal-report-prefix("\e[?1u" ~ 'hunter2'); # 5
terminal-report-prefix("\eP1+r5463=78\e\\"); # 13
terminal-report-prefix("\e[A"); # 0 — cursor up
terminal-report-prefix("\e[?64;1;2"); # 0 — incomplete
terminal-report-prefix('hunter2'); # 0
=end code
)
sub terminal-report-prefix(Str:D $text --> Int) is export {
return 0 unless $text.starts-with("\e");
return 0 if $text.chars < 3;
my Str $intro = $text.substr(1, 1);
return csi-report-prefix($text) if $intro eq '[';
return string-report-prefix($text) if $intro (elem) STRING-INTRODUCER;
0;
}
#|( CSI branch of C<terminal-report-prefix>. Walks private introducer,
parameters, intermediates and final in that order, bailing out to
C<0> the moment a character falls outside what the position
allows. )
my sub csi-report-prefix(Str:D $text --> Int) {
my Int $limit = $text.chars min MAX-REPORT-CHARS;
my Int $i = 2; # past ESC and '['
my Str $private = '';
if $i < $limit && $text.substr($i, 1) (elem) CSI-PRIVATE {
$private = $text.substr($i, 1);
++$i;
}
# SGR mouse reports have a stricter shape than generic CSI reports,
# but their coordinates may be signed when the pointer is outside
# the drawable plane. Keep that exception local to the mouse grammar.
if $private eq '<' {
my Int $mouse = sgr-mouse-report-prefix($text);
return $mouse if $mouse > 0;
}
my Int $params-start = $i;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-PARAM;
# A report always carries at least one parameter character. This is
# what keeps the cursor keys (\e[A, \e[H) out of the filter.
return 0 if $i == $params-start;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-INTERMEDIATE;
return 0 if $i >= $limit;
my Str $final = $text.substr($i, 1);
$final (elem) CSI-REPORT-FINAL ?? $i + 1 !! 0;
}
#|( SGR mouse branch of C<terminal-report-prefix>. The button code is
unsigned; each coordinate may carry a leading minus sign. Exactly
three non-empty decimal fields separated by semicolons are required. )
my sub sgr-mouse-report-prefix(Str:D $text --> Int) {
my Int $limit = $text.chars min MAX-REPORT-CHARS;
my Int $i = 3; # past ESC, '[' and '<'
my Int $button-start = $i;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-DIGIT;
return 0 if $i == $button-start || $i >= $limit
|| $text.substr($i, 1) ne ';';
++$i;
for ^2 {
++$i if $i < $limit && $text.substr($i, 1) eq '-';
my Int $coordinate-start = $i;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-DIGIT;
return 0 if $i == $coordinate-start;
if $_ == 0 {
return 0 if $i >= $limit || $text.substr($i, 1) ne ';';
++$i;
}
}
return 0 if $i >= $limit;
$text.substr($i, 1) (elem) CSI-MOUSE-FINAL ?? $i + 1 !! 0;
}
#|( Total length of the run of B<consecutive> complete terminal
reports at the head of C<$text> — C<0> when it does not start with
one.
Replies do not arrive one at a time. A terminal answering the
start-up probe burst sends DA1, DA2, the cursor position and the
window geometry back to back, and if the whole run is replayed as
input then the whole run has to come off the front, not just its
first member.
=begin code :lang<raku>
terminal-report-strip-length("\e[?62;22c\e[>1;10;0c"); # 20
terminal-report-strip-length("\e[?1u" ~ 'hunter2'); # 5
terminal-report-strip-length('hunter2'); # 0
=end code
)
sub terminal-report-strip-length(Str:D $text --> Int) is export {
my Int $total = 0;
loop {
my Int $matched = terminal-report-prefix($text.substr($total));
last if $matched == 0;
$total += $matched;
}
$total;
}
#|( True when C<$text> is not yet a complete terminal report but could
still become one if more characters arrive — every character so
far is legal for its position and no terminating byte has been
seen.
This is what lets the caller B<wait> rather than guess. The reply
to a capability query does not just arrive late; under load it
arrives in pieces, and notcurses replays each piece separately, so
the fragments reach Selkie in different input bursts. Recognising
a half-arrived report is what turns "dispatch C<[?64;1;2> into the
password field, then C<;6;9;15;18;21;22c> a frame later" into
"hold briefly, reassemble, drop".
=begin code :lang<raku>
terminal-report-viable("\e[?64;1;2"); # True — mid-parameters
terminal-report-viable("\e["); # True — mid-CSI
terminal-report-viable("\eP1+r5463"); # True — string, no ST yet
terminal-report-viable("\e[?64;1;2c"); # False — already complete
terminal-report-viable("\e[?64;1;2h"); # False — terminated, not a report
terminal-report-viable("\e[A"); # False — terminated, not a report
terminal-report-viable("\ehunter2"); # False — never was one
=end code
)
sub terminal-report-viable(Str:D $text --> Bool) is export {
return False unless $text.starts-with("\e");
return False if $text.chars > MAX-REPORT-CHARS;
return True if $text.chars == 1; # ESC, introducer still to come
return False if terminal-report-prefix($text) > 0;
my Str $intro = $text.substr(1, 1);
return csi-viable($text) if $intro eq '[';
# A string sequence stays viable until its ST arrives. An embedded
# ESC that is not the start of an ST is a new sequence beginning,
# which means this one was aborted and will never complete.
return !$text.substr(2).contains("\e") if $intro (elem) STRING-INTRODUCER;
False;
}
#|( CSI branch of C<terminal-report-viable>: viable while every
character past the introducer is a private marker, a parameter
character or an intermediate — i.e. while no final byte has been
reached. Reaching a final byte means the sequence is over, and
C<terminal-report-prefix> has already ruled it out as a report. )
my sub csi-viable(Str:D $text --> Bool) {
my Int $i = 2;
my Int $limit = $text.chars;
my Str $private = '';
if $i < $limit && $text.substr($i, 1) (elem) CSI-PRIVATE {
$private = $text.substr($i, 1);
++$i;
}
return True if $private eq '<' && sgr-mouse-viable($text);
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-PARAM;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-INTERMEDIATE;
$i >= $limit;
}
#|( True while an exact SGR mouse report is incomplete but every byte
received so far is legal for its position. )
my sub sgr-mouse-viable(Str:D $text --> Bool) {
my Int $i = 3; # past ESC, '[' and '<'
my Int $limit = $text.chars;
return True if $i >= $limit;
my Int $button-start = $i;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-DIGIT;
return False if $i == $button-start;
return True if $i >= $limit;
return False if $text.substr($i, 1) ne ';';
++$i;
for ^2 {
return True if $i >= $limit;
++$i if $text.substr($i, 1) eq '-';
return True if $i >= $limit;
my Int $coordinate-start = $i;
++$i while $i < $limit && $text.substr($i, 1) (elem) CSI-DIGIT;
return False if $i == $coordinate-start;
return True if $i >= $limit;
if $_ == 0 {
return False if $text.substr($i, 1) ne ';';
++$i;
} else {
# A final byte means the sequence is complete or malformed,
# never still growing. The caller already ruled out complete
# reports before reaching this helper.
return False;
}
}
False;
}
#|( DCS / OSC / APC / PM branch: everything up to the first ST
(C<ESC \>), or — for OSC only, which is the one sequence terminals
still terminate the old way — a BEL. An embedded ESC that is not
the start of an ST aborts the match: that is a new sequence
beginning, not payload. )
my sub string-report-prefix(Str:D $text --> Int) {
my Bool $osc = $text.substr(1, 1) eq ']';
my Int $limit = $text.chars min MAX-REPORT-CHARS;
my Int $i = 2;
while $i < $limit {
my Str $c = $text.substr($i, 1);
if $c eq "\e" {
# ST is the only continuation an ESC inside a string
# sequence can legitimately be.
return $i + 1 < $limit && $text.substr($i + 1, 1) eq '\\'
?? $i + 2
!! 0;
}
return $i + 1 if $osc && $c eq "\a";
++$i;
}
0;
}