basic: add simple ansi sequence parser

This tries to generalize ANSI sequence handling a bit, and will track
and capture ANSI sequences in a more comprehensive fashion.
This commit is contained in:
Lennart Poettering
2026-09-22 15:57:58 +02:00
committed by Luca Boccassi
parent efa16c7f31
commit b8da2dc98e
5 changed files with 556 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include "alloc-util.h"
#include "ansi-seq.h"
#include "string-util.h"
void ansi_seq_parser_done(AnsiSeqParser *p) {
assert(p);
p->string = mfree(p->string);
p->string_len = 0;
}
static void ansi_seq_parser_begin_string(AnsiSeqParser *p, char introducer) {
assert(p);
p->string_len = 0;
if (p->string)
p->string[0] = 0;
p->bad = false;
p->introducer = introducer;
}
static int ansi_seq_parser_push_string(AnsiSeqParser *p, char c) {
assert(p);
assert(!p->bad); /* only ever set together with a reset to the ground state, hence never seen here */
/* Safety check: don't capture unbounded string sequences */
if (p->string_len >= ANSI_SEQ_STRING_MAX)
return 0; /* too long */
if (p->capture) {
if (!GREEDY_REALLOC(p->string, p->string_len + 2))
return -ENOMEM;
p->string[p->string_len+0] = c;
p->string[p->string_len+1] = 0;
}
p->string_len++;
return 1; /* added */
}
static int ansi_seq_parser_bad(AnsiSeqParser *p) {
assert(p);
/* A bad sequence has been received, reset state to ground, and remember this */
p->state = ANSI_SEQ_STATE_GROUND;
p->bad = true;
return ANSI_SEQ_EVENT_ABORT;
}
int ansi_seq_parser_feed(AnsiSeqParser *p, char c) {
uint8_t u = (uint8_t) c;
int r;
assert(p);
switch (p->state) {
case ANSI_SEQ_STATE_GROUND:
if (c == 0x1B) { /* ESC */
p->state = ANSI_SEQ_STATE_ESC;
ansi_seq_parser_begin_string(p, /* introducer= */ 0);
return ANSI_SEQ_EVENT_SEQUENCE;
}
return ANSI_SEQ_EVENT_TEXT;
case ANSI_SEQ_STATE_ESC:
switch (c) {
case '[': /* CSI sequence */
p->state = ANSI_SEQ_STATE_CSI;
ansi_seq_parser_begin_string(p, c);
return ANSI_SEQ_EVENT_SEQUENCE;
case ']': /* OSC */
case 'P': /* DCS */
case 'X': /* SOS */
case '^': /* PM */
case '_': /* APC */
p->state = ANSI_SEQ_STATE_STRING;
ansi_seq_parser_begin_string(p, c);
return ANSI_SEQ_EVENT_SEQUENCE;
case 0x40 ... 0x4f: /* Fe sequence, i.e. the 7-bit form of the C1 controls (e.g. ESC M = RI),
* except for those introducing CSI/string sequences, handled above */
case 0x51 ... 0x57:
case 0x59 ... 0x5a:
case 0x5c:
case 0x60 ... 0x7e: /* Fs sequence */
case 0x30 ... 0x3f: /* Fp sequence */
p->state = ANSI_SEQ_STATE_GROUND;
p->introducer = c;
return ANSI_SEQ_EVENT_END;
case 0x20 ... 0x2f: /* nF sequence */
p->state = ANSI_SEQ_STATE_NF;
ansi_seq_parser_begin_string(p, c);
return ANSI_SEQ_EVENT_SEQUENCE;
default:
/* Anything else is not a valid escape sequence, give up on it */
return ansi_seq_parser_bad(p);
}
case ANSI_SEQ_STATE_CSI:
if (u >= 0x40 && u <= 0x7E) { /* final byte, the sequence is complete */
r = ansi_seq_parser_push_string(p, c);
if (r < 0)
return r;
if (r == 0)
return ansi_seq_parser_bad(p);
p->state = ANSI_SEQ_STATE_GROUND;
return ANSI_SEQ_EVENT_END;
}
if (u >= 0x20 && u <= 0x3F) {
/* parameter or intermediate byte, the sequence continues */
r = ansi_seq_parser_push_string(p, c);
if (r < 0)
return r;
if (r > 0)
return ANSI_SEQ_EVENT_SEQUENCE;
/* fall through on too long */
}
/* Anything else is not a valid escape sequence, give up on it */
return ansi_seq_parser_bad(p);
case ANSI_SEQ_STATE_STRING:
if (c == 0x07) { /* BEL, the single character ST */
p->state = ANSI_SEQ_STATE_GROUND;
return ANSI_SEQ_EVENT_END;
}
if (c == 0x1B) { /* Presumably the beginning of the two character ST. Note that we do not
* support the C1 encoding of ST (0x9C), since that's a valid UTF-8
* codepoint and would hence be ambiguous. */
p->state = ANSI_SEQ_STATE_STRING_ESC;
return ANSI_SEQ_EVENT_SEQUENCE;
}
if (u >= 0x20U) { /* regular payload */
r = ansi_seq_parser_push_string(p, c);
if (r < 0)
return r;
if (r > 0)
return ANSI_SEQ_EVENT_SEQUENCE;
/* fall through on too long */
}
/* Any other control character: give up on this sequence */
return ansi_seq_parser_bad(p);
case ANSI_SEQ_STATE_STRING_ESC:
if (c == '\\') { /* ST is complete, and so is the string sequence */
p->state = ANSI_SEQ_STATE_GROUND;
return ANSI_SEQ_EVENT_END;
}
return ansi_seq_parser_bad(p);
case ANSI_SEQ_STATE_NF:
if (u >= 0x30 && u <= 0x7E) { /* final byte, the sequence is complete */
r = ansi_seq_parser_push_string(p, c);
if (r < 0)
return r;
if (r == 0)
return ansi_seq_parser_bad(p);
p->state = ANSI_SEQ_STATE_GROUND;
return ANSI_SEQ_EVENT_END;
}
if (u >= 0x20 && u <= 0x2F) {
/* parameter or intermediate byte, the sequence continues */
r = ansi_seq_parser_push_string(p, c);
if (r < 0)
return r;
if (r > 0)
return ANSI_SEQ_EVENT_SEQUENCE;
/* fall through on too long */
}
/* Anything else is not a valid escape sequence, give up on it */
return ansi_seq_parser_bad(p);
default:
assert_not_reached();
}
}
int ansi_seq_parser_feed_harder(AnsiSeqParser *p, char c) {
int r;
assert(p);
/* Just like ansi_seq_parser_feed(), but automatically eat up abort events, and resubmit the bad character. */
do {
r = ansi_seq_parser_feed(p, c);
} while (r == ANSI_SEQ_EVENT_ABORT);
return r;
}
const char* ansi_seq_parser_string(const AnsiSeqParser *p) {
assert(p);
if (!p->capture || p->bad || p->introducer == 0)
return NULL;
return strempty(p->string);
}
+56
View File
@@ -0,0 +1,56 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include "forward.h"
/* A minimal, generic state engine that tracks ANSI escape sequence boundaries in a byte stream. It does not
* interpret sequences, it only determines where they begin and end, so that callers can find "safe"
* positions in a stream at which generated sequences may be inserted without corrupting a sequence already
* in progress. Optionally, the payload of sequences is captured, so that callers may act on specific
* sequences: for CSI and nF sequences that's the parameter, intermediate and final bytes, for "string"
* sequences (OSC and friends) it's the string itself. */
typedef enum AnsiSeqState {
ANSI_SEQ_STATE_GROUND, /* regular characters */
ANSI_SEQ_STATE_ESC, /* ESC seen, or within an escape sequence that is neither CSI nor a string sequence */
ANSI_SEQ_STATE_CSI, /* within a CSI sequence */
ANSI_SEQ_STATE_STRING, /* within a string sequence (OSC, DCS, SOS, PM, APC) */
ANSI_SEQ_STATE_STRING_ESC, /* within a string sequence, ESC seen (presumably the beginning of ST) */
ANSI_SEQ_STATE_NF, /* within an NF sequence */
_ANSI_SEQ_STATE_MAX,
_ANSI_SEQ_STATE_INVALID = -EINVAL,
} AnsiSeqState;
typedef enum AnsiSeqEvent {
ANSI_SEQ_EVENT_TEXT, /* a regular character, not part of any sequence */
ANSI_SEQ_EVENT_SEQUENCE, /* a character belonging to a sequence that is not complete yet */
ANSI_SEQ_EVENT_END, /* the character completes a sequence, its payload may be queried */
ANSI_SEQ_EVENT_ABORT, /* the character aborted a sequence and was not consumed: feed it again! */
_ANSI_SEQ_EVENT_MAX,
_ANSI_SEQ_EVENT_INVALID = -EINVAL,
} AnsiSeqEvent;
/* Maximum length of a sequence payload we are willing to process. A sequence that grows beyond this is
* considered invalid and aborted. */
#define ANSI_SEQ_STRING_MAX 192U
typedef struct AnsiSeqParser {
AnsiSeqState state;
char introducer; /* for string sequences: the character that introduced it, e.g. ']' for OSC */
char *string; /* the accumulated payload of the most recent string sequence */
size_t string_len;
bool bad; /* the current string sequence grew beyond ANSI_SEQ_STRING_MAX or is otherwise invalid */
bool capture; /* if true we'll capture the sequence */
} AnsiSeqParser;
void ansi_seq_parser_done(AnsiSeqParser *p);
/* Feeds a single character, returns an AnsiSeqEvent, or a negative errno on capture allocation failure */
int ansi_seq_parser_feed(AnsiSeqParser *p, char c);
/* Just like this, but eat up errors, and immediately submit character again */
int ansi_seq_parser_feed_harder(AnsiSeqParser *p, char c);
/* After ANSI_SEQ_EVENT_END: the captured payload as NUL terminated string, or NULL if capturing is
* off or the payload overflowed */
const char* ansi_seq_parser_string(const AnsiSeqParser *p);
+1
View File
@@ -5,6 +5,7 @@ basic_sources = files(
'af-list.c',
'alloc-util.c',
'ansi-color.c',
'ansi-seq.c',
'architecture.c',
'argv-util.c',
'arphrd-util.c',
+1
View File
@@ -57,6 +57,7 @@ generated_sources += [test_libsystemd_sym_c, test_libudev_sym_c]
simple_tests += files(
'test-alloc-util.c',
'test-ansi-seq.c',
'test-architecture.c',
'test-argv-util.c',
'test-arphrd-util.c',
+276
View File
@@ -0,0 +1,276 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include "alloc-util.h"
#include "ansi-seq.h"
#include "escape.h"
#include "tests.h"
static void feed_string(AnsiSeqParser *p, const char *s, const char *expected_events) {
size_t e = 0;
int r;
_cleanup_free_ char *escaped = ASSERT_PTR(cescape(s));
log_info("looking at: %s", escaped);
/* NB: an aborted character is fed twice and hence produces two events, i.e. the expected event
* string may be longer than the input string */
for (size_t i = 0; s[i]; i++)
for (;;) {
r = ansi_seq_parser_feed(p, s[i]);
ASSERT_OK(r);
AnsiSeqEvent expected;
switch (expected_events[e++]) {
case 't':
expected = ANSI_SEQ_EVENT_TEXT;
break;
case 's':
expected = ANSI_SEQ_EVENT_SEQUENCE;
break;
case 'e':
expected = ANSI_SEQ_EVENT_END;
break;
case 'a':
expected = ANSI_SEQ_EVENT_ABORT;
break;
default:
assert_not_reached();
}
ASSERT_EQ(r, (int) expected);
if (r != ANSI_SEQ_EVENT_ABORT) /* aborted characters must be fed again */
break;
}
ASSERT_EQ(expected_events[e], '\0');
}
TEST(ansi_seq_parser) {
_cleanup_(ansi_seq_parser_done) AnsiSeqParser p = { .capture = true };
/* Regular text */
feed_string(&p, "hello\n\r", "ttttttt");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* A CSI color sequence */
feed_string(&p, "\x1B[0;31m", "sssssse");
ASSERT_EQ(p.introducer, '[');
ASSERT_STREQ(ansi_seq_parser_string(&p), "0;31m");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* A two-character escape sequence */
feed_string(&p, "\x1B" "c", "se");
ASSERT_EQ(p.introducer, 'c');
ASSERT_STREQ(ansi_seq_parser_string(&p), "");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* A CSI sequence with DEC private parameters */
feed_string(&p, "\x1B[?25l", "ssssse");
ASSERT_EQ(p.introducer, '[');
ASSERT_STREQ(ansi_seq_parser_string(&p), "?25l");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* An nF escape sequence */
feed_string(&p, "\x1B(B", "sse");
ASSERT_EQ(p.introducer, '(');
ASSERT_STREQ(ansi_seq_parser_string(&p), "B");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* An nF escape sequence with a 0x2F intermediate byte */
feed_string(&p, "\x1B#/0", "ssse");
ASSERT_EQ(p.introducer, '#');
ASSERT_STREQ(ansi_seq_parser_string(&p), "/0");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* An OSC sequence terminated by BEL */
feed_string(&p, "\x1B]0;title\a", "ssssssssse");
ASSERT_EQ(p.introducer, ']');
ASSERT_STREQ(ansi_seq_parser_string(&p), "0;title");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* An OSC sequence terminated by ST, interleaved feeding */
feed_string(&p, "\x1B]28", "ssss");
feed_string(&p, "11;width=80;height=24\x1B\\", "sssssssssssssssssssssse");
ASSERT_EQ(p.introducer, ']');
ASSERT_STREQ(ansi_seq_parser_string(&p), "2811;width=80;height=24");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* A DCS sequence: tracked, payload captured with 'P' introducer */
feed_string(&p, "\x1BPxyz\x1B\\", "sssssse");
ASSERT_EQ(p.introducer, 'P');
ASSERT_STREQ(ansi_seq_parser_string(&p), "xyz");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* An aborted OSC sequence: the \n is replayed as text */
feed_string(&p, "\x1B]123\n", "sssssat");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
/* An overlong string sequence */
feed_string(&p, "\x1B]", "ss");
for (size_t i = 0; i < ANSI_SEQ_STRING_MAX; i++)
ASSERT_EQ(ansi_seq_parser_feed(&p, 'x'), ANSI_SEQ_EVENT_SEQUENCE);
ASSERT_EQ(ansi_seq_parser_feed(&p, 'x'), ANSI_SEQ_EVENT_ABORT);
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
/* Capturing works again afterwards */
feed_string(&p, "\x1B]foo\a", "ssssse");
ASSERT_EQ(p.introducer, ']');
ASSERT_STREQ(ansi_seq_parser_string(&p), "foo");
/* The other string sequence introducers: SOS, PM, APC */
feed_string(&p, "\x1BXsos\x1B\\", "sssssse");
ASSERT_EQ(p.introducer, 'X');
ASSERT_STREQ(ansi_seq_parser_string(&p), "sos");
feed_string(&p, "\x1B^pm\a", "sssse");
ASSERT_EQ(p.introducer, '^');
ASSERT_STREQ(ansi_seq_parser_string(&p), "pm");
feed_string(&p, "\x1B_apc\a", "ssssse");
ASSERT_EQ(p.introducer, '_');
ASSERT_STREQ(ansi_seq_parser_string(&p), "apc");
/* A two-character Fp escape sequence (DECSC) */
feed_string(&p, "\x1B" "7", "se");
ASSERT_EQ(p.introducer, '7');
ASSERT_STREQ(ansi_seq_parser_string(&p), "");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* Two-character Fe escape sequences, i.e. 7-bit encoded C1 controls (RI, NEL, a stray ST) */
feed_string(&p, "\x1BM", "se");
ASSERT_EQ(p.introducer, 'M');
ASSERT_STREQ(ansi_seq_parser_string(&p), "");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
feed_string(&p, "\x1B" "E", "se");
ASSERT_EQ(p.introducer, 'E');
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
feed_string(&p, "\x1B\\", "se");
ASSERT_EQ(p.introducer, '\\');
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* ESC followed by something that cannot start a sequence: aborted, the character is replayed */
feed_string(&p, "\x1B\n", "sat");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
/* An ESC inside a string sequence that is not followed by '\' (i.e. not ST): aborted, the
* character following the ESC is replayed */
feed_string(&p, "\x1B]foo\x1B" "x", "ssssssat");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
/* An overlong CSI sequence: aborted, even at its final byte */
feed_string(&p, "\x1B[", "ss");
for (size_t i = 0; i < ANSI_SEQ_STRING_MAX; i++)
ASSERT_EQ(ansi_seq_parser_feed(&p, ';'), ANSI_SEQ_EVENT_SEQUENCE);
ASSERT_EQ(ansi_seq_parser_feed(&p, 'm'), ANSI_SEQ_EVENT_ABORT);
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
ASSERT_EQ(ansi_seq_parser_feed(&p, 'm'), ANSI_SEQ_EVENT_TEXT);
/* Ditto for an overlong nF sequence */
feed_string(&p, "\x1B(", "ss");
for (size_t i = 0; i < ANSI_SEQ_STRING_MAX; i++)
ASSERT_EQ(ansi_seq_parser_feed(&p, ' '), ANSI_SEQ_EVENT_SEQUENCE);
ASSERT_EQ(ansi_seq_parser_feed(&p, 'B'), ANSI_SEQ_EVENT_ABORT);
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
ASSERT_EQ(ansi_seq_parser_feed(&p, 'B'), ANSI_SEQ_EVENT_TEXT);
/* And an overlong CSI sequence that continues past the limit with a non-final byte */
feed_string(&p, "\x1B[", "ss");
for (size_t i = 0; i < ANSI_SEQ_STRING_MAX; i++)
ASSERT_EQ(ansi_seq_parser_feed(&p, '1'), ANSI_SEQ_EVENT_SEQUENCE);
ASSERT_EQ(ansi_seq_parser_feed(&p, '1'), ANSI_SEQ_EVENT_ABORT);
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_EQ(ansi_seq_parser_feed(&p, '1'), ANSI_SEQ_EVENT_TEXT);
}
static void feed_string_harder(AnsiSeqParser *p, const char *s, const char *expected_events) {
size_t e = 0;
_cleanup_free_ char *escaped = ASSERT_PTR(cescape(s));
log_info("looking at (harder): %s", escaped);
/* Unlike feed_string() this never sees abort events, as ansi_seq_parser_feed_harder() swallows
* them, hence the expected event string has exactly one event per input character */
for (size_t i = 0; s[i]; i++) {
int r = ansi_seq_parser_feed_harder(p, s[i]);
ASSERT_OK(r);
ASSERT_NE(r, (int) ANSI_SEQ_EVENT_ABORT);
switch (expected_events[e++]) {
case 't':
ASSERT_EQ(r, (int) ANSI_SEQ_EVENT_TEXT);
break;
case 's':
ASSERT_EQ(r, (int) ANSI_SEQ_EVENT_SEQUENCE);
break;
case 'e':
ASSERT_EQ(r, (int) ANSI_SEQ_EVENT_END);
break;
default:
assert_not_reached();
}
}
ASSERT_EQ(expected_events[e], '\0');
}
TEST(ansi_seq_parser_feed_harder) {
_cleanup_(ansi_seq_parser_done) AnsiSeqParser p = { .capture = true };
/* Complete sequences work as with ansi_seq_parser_feed() */
feed_string_harder(&p, "a\x1B[1mb", "tssset");
ASSERT_STREQ(ansi_seq_parser_string(&p), "1m");
/* An aborted OSC sequence: the aborting character is resubmitted internally and comes out as text */
feed_string_harder(&p, "\x1B]123\nx", "ssssstt");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(ansi_seq_parser_string(&p));
/* An aborted CSI sequence whose aborting character (ESC) starts a new sequence */
feed_string_harder(&p, "\x1B[1\x1B[m", "ssssse");
ASSERT_STREQ(ansi_seq_parser_string(&p), "m");
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
/* ESC ESC: the first ESC is dropped, the second one starts a sequence */
feed_string_harder(&p, "\x1B\x1B[m", "ssse");
ASSERT_STREQ(ansi_seq_parser_string(&p), "m");
}
TEST(ansi_seq_parser_no_capture) {
_cleanup_(ansi_seq_parser_done) AnsiSeqParser p = {}; /* capture off */
/* Boundaries are still tracked, but no payload is ever made available */
feed_string(&p, "\x1B]0;title\a", "ssssssssse");
ASSERT_EQ(p.introducer, ']');
ASSERT_NULL(ansi_seq_parser_string(&p));
ASSERT_NULL(p.string);
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
feed_string(&p, "\x1B[0;31m", "sssssse");
ASSERT_EQ(p.introducer, '[');
ASSERT_NULL(ansi_seq_parser_string(&p));
ASSERT_NULL(p.string);
/* The length limit applies regardless */
feed_string(&p, "\x1B]", "ss");
for (size_t i = 0; i < ANSI_SEQ_STRING_MAX; i++)
ASSERT_EQ(ansi_seq_parser_feed(&p, 'x'), ANSI_SEQ_EVENT_SEQUENCE);
ASSERT_EQ(ansi_seq_parser_feed(&p, 'x'), ANSI_SEQ_EVENT_ABORT);
ASSERT_EQ(p.state, ANSI_SEQ_STATE_GROUND);
ASSERT_NULL(p.string);
}
DEFINE_TEST_MAIN(LOG_INFO);