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;
}